From afec06dad3b9fd6369070e6328cc406e341fd9cd Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 23 Sep 2026 23:35:05 -0500 Subject: [PATCH 1/5] feat(sdk)!: reshape dash-platform-cxx into a thin shell over dash-sdk packages/rs-platform-cxx rebuilt on v4.2-dev with dash-sdk (default-features off: dpns-contract, dashpay-contract, core_key_wallet) and platform-encryption as its only Platform dependencies; the direct dpp/drive/platform-version/context-provider/dash-platform-queries dependencies, the #4632 builders and the decode/st/queries modules are gone. Bridge (namespace platform_ffi): Config{network, tenderdash_chain_id, platform_llmq_type, proxy}; nine-kind Status; one Verified* struct per read with typed ProvenAbsent (also under an unsupported protocol version, which meta then shows; the default value could not tell absence apart); one page per bridge call with a StartAfter cursor, has_more computed against the query's own limit (at PV13 Drive answers a names_of_identity continuation with an empty page); typed BroadcastResult; builders returning Built{bytes, hash, object_id}; pure DPNS/DIP-15 helpers. Every entry, including new_platform_client and Drop, runs under catch_unwind, and the crate refuses panic=abort at compile time as well as in build.rs. provider.rs is the push-model ContextProvider: LLMQ-type gate, quorum-hash normalization from Core's internal uint256 order, refusal of every proof until a local ChainLock height is pushed, a 288-block ChainLock-lag floor with no ceiling, and the compiled-in DPNS/DashPay contracts cached per protocol version with negative entries. Provider errors map to Status by variant, also when raised inside proof verification. client.rs builds the SDK lazily from the https endpoint set the embedder pushes (any other scheme is refused: the address list would dial http in the clear; the host must be an IP address, or an onion name when a proxy is set, so nothing is resolved locally). Config.proxy{kind, address, isolate} is the embedder's SOCKS5 proxy (none, a numeric TCP address or a Unix socket; isolate = fresh random credentials per connection, one Tor circuit each), fixed for the client's lifetime and passed to every SDK it builds through SdkBuilder::with_proxy, with a 15 s connect budget instead of 5 s; a proxy the shell cannot use fails new_platform_client, so there is never a direct fallback. A proxy failure is Unavailable (the SDK neither retries nor bans for it), not Rejected. The DAPI client keeps a pooled channel, and so an open connection, per evonode it has talked to, and removing an address from the list does not touch the pool, which is private to the client: an empty set therefore drops the SDK (no Platform socket stays open while the embedder has disabled networking), a set that removes endpoints rebuilds the SDK over the same, updated AddressList (retained entries keep their ban state), and a set that only adds updates the list in place. The provider, the height watermark and the verified protocol version belong to the client and survive every rebuild; a rebuilt SDK is seeded at the verified version. Proved reads are not dispatched before a ChainLock anchor is pushed or after shutdown. The SDK watermark is off; ops.rs checks the chain id, keeps a tolerance-3 Platform-height watermark keyed after it, records the protocol version of every accepted response, and signals UnsupportedProtocolVersion while still returning the value. Builders and contested_vote_fund_credits take that verified version (transition rules and the contested prefund changed across versions), so they fail before the first accepted read, except on a devnet whose floor is the latest version, and once a version this build does not know was seen. Document queries load the compiled-in contracts at this build's latest version, since a network SDK seeded at the PV13 floor could not decode a DashPay v2 profile. A node's definitive (non-retryable) gRPC refusal of a read or a broadcast, including tonic's answer to a response above the 4 MiB decoding bound, is Rejected; no answer is Unavailable. search_names refuses an empty or over-long prefix before dispatch, which Drive would refuse anyway. runtime.rs owns the tokio runtime and aborts the in-flight task on shutdown. signer.rs: BytesSigner hands the full signable preimage to WalletSigner::SignForKey; AssetLockSigner is the single digest path and recovers the public key from the compact signature (compressed-key header only, as Core funds P2PKH of the compressed key), so PublicKeyForKey is gone. The adapters take a SignerCallbacks trait whose Send + Sync impls sit on the extern type under the any-thread contract signer.h states. builders.rs: identity create through dpp try_from_identity_with_signers, DPNS preorder/domain and the profile create assembled inline until rs-sdk exports them, each create given the document id put_to_platform derives from its entropy and nonce at the verified version (dpp derives it itself only from PV14; up to PV13 it sends the document's id as is, and a zero id is refused by Drive with InvalidDocumentTransitionIdError), a property the network's contract does not have yet refused before signing, contact requests through Sdk::create_contact_request with client-side ECDH under the verified version. build_profile replaces the Profile a get_profile read at its revision + 1 and carries the fields the embedder does not edit (avatar, DashPay v2 payment addresses) into the replacement, since a replace transition carries the whole document and would otherwise erase what another wallet set. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../package-filters/rs-packages-direct.yml | 3 + .../rs-packages-no-workflows.yml | 5 + .github/package-filters/rs-packages.yml | 6 + Cargo.lock | 148 ++- Cargo.toml | 1 + packages/rs-platform-cxx/Cargo.toml | 35 + packages/rs-platform-cxx/build.rs | 61 ++ .../include/dash/platform/signer.h | 95 ++ packages/rs-platform-cxx/scripts/cxx-smoke.sh | 35 + packages/rs-platform-cxx/src/builders.rs | 662 ++++++++++++ packages/rs-platform-cxx/src/client.rs | 918 +++++++++++++++++ packages/rs-platform-cxx/src/helpers.rs | 267 +++++ packages/rs-platform-cxx/src/lib.rs | 963 ++++++++++++++++++ packages/rs-platform-cxx/src/ops.rs | 950 +++++++++++++++++ packages/rs-platform-cxx/src/provider.rs | 315 ++++++ packages/rs-platform-cxx/src/runtime.rs | 138 +++ packages/rs-platform-cxx/src/signer.rs | 185 ++++ packages/rs-platform-cxx/src/sync.rs | 21 + packages/rs-platform-cxx/tests/cxx_smoke.cc | 247 +++++ 19 files changed, 5040 insertions(+), 15 deletions(-) create mode 100644 packages/rs-platform-cxx/Cargo.toml create mode 100644 packages/rs-platform-cxx/build.rs create mode 100644 packages/rs-platform-cxx/include/dash/platform/signer.h create mode 100755 packages/rs-platform-cxx/scripts/cxx-smoke.sh create mode 100644 packages/rs-platform-cxx/src/builders.rs create mode 100644 packages/rs-platform-cxx/src/client.rs create mode 100644 packages/rs-platform-cxx/src/helpers.rs create mode 100644 packages/rs-platform-cxx/src/lib.rs create mode 100644 packages/rs-platform-cxx/src/ops.rs create mode 100644 packages/rs-platform-cxx/src/provider.rs create mode 100644 packages/rs-platform-cxx/src/runtime.rs create mode 100644 packages/rs-platform-cxx/src/signer.rs create mode 100644 packages/rs-platform-cxx/src/sync.rs create mode 100644 packages/rs-platform-cxx/tests/cxx_smoke.cc diff --git a/.github/package-filters/rs-packages-direct.yml b/.github/package-filters/rs-packages-direct.yml index b74139a2523..a87db6967d8 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -131,6 +131,9 @@ dash-platform-queries: dash-sdk: - packages/rs-sdk/** +dash-platform-cxx: + - packages/rs-platform-cxx/** + rs-sdk-ffi: - packages/rs-sdk-ffi/** diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index 5c59193d383..b700812faf8 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -153,6 +153,11 @@ dash-sdk: &sdk - *dapi_client - *drive +dash-platform-cxx: + - packages/rs-platform-cxx/** + - *sdk + - *platform_encryption + rs-sdk-ffi: &sdk_ffi - packages/rs-sdk-ffi/** - *simple-signer diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index 91dd8c681ba..b824ce4c747 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -181,6 +181,12 @@ dash-sdk: &sdk - *dapi_client - *drive +dash-platform-cxx: + - .github/workflows/tests* + - packages/rs-platform-cxx/** + - *sdk + - *platform_encryption + rs-sdk-ffi: &sdk_ffi - .github/workflows/tests* - packages/rs-sdk-ffi/** diff --git a/Cargo.lock b/Cargo.lock index 1fc2a06672e..271978b07f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -568,7 +568,7 @@ dependencies = [ "bitflags 2.13.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -1203,6 +1203,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1221,7 +1232,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1541,6 +1552,68 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "cxx" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash 0.2.0", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2" +dependencies = [ + "cc", + "codespan-reporting", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "scratch", + "syn 3.0.5", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d" +dependencies = [ + "clap", + "codespan-reporting", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c" +dependencies = [ + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "dapi-grpc" version = "5.0.0-beta.2" @@ -1685,6 +1758,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "dash-platform-cxx" +version = "5.0.0-beta.2" +dependencies = [ + "async-trait", + "cxx", + "cxx-build", + "dash-sdk", + "futures", + "hex", + "platform-encryption", + "tokio", + "zeroize", +] + [[package]] name = "dash-platform-macros" version = "5.0.0-beta.2" @@ -2492,7 +2580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3647,7 +3735,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -3898,7 +3986,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4329,6 +4417,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "link-cplusplus" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +dependencies = [ + "cc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4734,7 +4831,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5630,7 +5727,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck 0.5.0", - "itertools 0.10.5", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -5651,7 +5748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5664,7 +5761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5800,7 +5897,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -5838,7 +5935,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", "windows-sys 0.59.0", ] @@ -6674,7 +6771,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6733,7 +6830,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6853,6 +6950,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + [[package]] name = "scrypt" version = "0.11.0" @@ -7595,7 +7698,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7654,6 +7757,15 @@ dependencies = [ "zip 8.6.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termtree" version = "0.5.1" @@ -8482,6 +8594,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -9053,7 +9171,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1236ac5c9ea..37734a3b152 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ members = [ "packages/app-connect-contract", "packages/moderation-charters-contract", "packages/rs-sdk-ffi", + "packages/rs-platform-cxx", "packages/wasm-drive-verify", "packages/dash-platform-balance-checker", "packages/rs-dapi", diff --git a/packages/rs-platform-cxx/Cargo.toml b/packages/rs-platform-cxx/Cargo.toml new file mode 100644 index 00000000000..521963c89d0 --- /dev/null +++ b/packages/rs-platform-cxx/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "dash-platform-cxx" +version.workspace = true +authors = ["Dash Core Group "] +edition = "2021" +rust-version.workspace = true +license = "MIT" +description = "CXX shell over dash-sdk for C++ embedders that supply their own trust context and signing keys" + +[lib] +name = "dash_platform_cxx" +crate-type = ["staticlib", "rlib"] + +[features] +default = [] +# dash-sdk's mock transport, for tests that replay recorded proved responses +# through the same client code an embedder runs. Never enabled by embedders. +mocks = ["dash-sdk/mocks"] + +[dependencies] +cxx = "1.0" +dash-sdk = { path = "../rs-sdk", default-features = false, features = [ + "dpns-contract", + "dashpay-contract", + "core_key_wallet", +] } +platform-encryption = { path = "../rs-platform-encryption" } +tokio = { version = "1.40", features = ["rt-multi-thread", "net", "time"] } +futures = "0.3" +async-trait = "0.1" +zeroize = "1.8" +hex = "0.4" + +[build-dependencies] +cxx-build = "1.0" diff --git a/packages/rs-platform-cxx/build.rs b/packages/rs-platform-cxx/build.rs new file mode 100644 index 00000000000..b2e949005ef --- /dev/null +++ b/packages/rs-platform-cxx/build.rs @@ -0,0 +1,61 @@ +use std::path::Path; +use std::{env, fs}; + +/// Generates the CXX bridge and stages every header an embedder includes +/// under `target//include/`, the way the cbindgen-based FFI crates +/// in this workspace stage theirs (`rs-sdk-ffi`, `rs-platform-wallet-ffi`): +/// `dash/platform/ffi.h` (the generated bridge header), `rust/cxx.h` (the +/// cxx runtime it includes) and `dash/platform/signer.h` (the hand-written +/// callback type the bridge's `extern "C++"` block includes). Build systems +/// install that `include/` tree and the static archive; nothing else in the +/// crate directory is part of the interface. +fn main() { + // Every bridge entry point converts panics into rust::Error through + // catch_unwind; under panic=abort that protection is silently compiled + // out and a panic aborts the embedding process. Refuse such a build. + println!("cargo:rerun-if-env-changed=CARGO_CFG_PANIC"); + if env::var("CARGO_CFG_PANIC").as_deref() == Ok("abort") { + panic!( + "dash-platform-cxx requires panic = \"unwind\"; its FFI guards rely on catch_unwind" + ); + } + + cxx_build::CFG.include_prefix = "dash/platform"; + cxx_build::bridge("src/lib.rs") + .include("include") + .std("c++20") + .compile("dash-platform-cxx-bridge"); + + println!("cargo:rerun-if-changed=src/"); + println!("cargo:rerun-if-changed=include/dash/platform/signer.h"); + + let out_dir = env::var("OUT_DIR").expect("OUT_DIR"); + let target_dir = Path::new(&out_dir) + .ancestors() + .nth(3) + .expect("target/ directory"); + let include_dir = target_dir.join("include"); + let platform_dir = include_dir.join("dash").join("platform"); + let rust_dir = include_dir.join("rust"); + fs::create_dir_all(&platform_dir).expect("create include dir"); + fs::create_dir_all(&rust_dir).expect("create include dir"); + + let generated = Path::new(&out_dir).join("cxxbridge"); + copy( + &generated.join("include/dash/platform/src/lib.rs.h"), + &platform_dir.join("ffi.h"), + ); + copy( + &generated.join("include/rust/cxx.h"), + &rust_dir.join("cxx.h"), + ); + copy( + Path::new("include/dash/platform/signer.h"), + &platform_dir.join("signer.h"), + ); +} + +fn copy(from: &Path, to: &Path) { + fs::copy(from, to) + .unwrap_or_else(|e| panic!("copy {} to {}: {e}", from.display(), to.display())); +} diff --git a/packages/rs-platform-cxx/include/dash/platform/signer.h b/packages/rs-platform-cxx/include/dash/platform/signer.h new file mode 100644 index 00000000000..a62b3b762b1 --- /dev/null +++ b/packages/rs-platform-cxx/include/dash/platform/signer.h @@ -0,0 +1,95 @@ +// 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 DASH_PLATFORM_CXX_SIGNER_H +#define DASH_PLATFORM_CXX_SIGNER_H + +#include + +#include +#include +#include +#include +#include +#include + +namespace platform_ffi { + +//! Signer handed by reference into the Rust state-transition builders. The +//! builders run to completion on the calling thread, so both callbacks are +//! only ever invoked on the thread that called the builder. The type is +//! nonetheless required to be callable from any thread (the Rust side +//! declares it Send + Sync on that basis): the callbacks must take the +//! wallet's own lock and must not rely on thread-local state. +//! +//! SignForKey receives the id of the identity key to sign with and the full +//! signable preimage of the transition; the embedder hashes it (double +//! SHA256) itself and must answer with a 65-byte compact recoverable ECDSA +//! signature. SignAssetLockSighash is the one digest path: the asset-lock +//! outpoint key of an identity registration signs the 32-byte double SHA256 +//! the builder computed; the key is compressed, so the header byte is +//! 31 + recovery id. Private keys never cross the FFI boundary. +class WalletSigner +{ +public: + using SignForKeyFn = std::function signable, + std::vector& sig_out)>; + using SignAssetLockFn = std::function& sighash, + std::vector& sig_out)>; + + WalletSigner(SignForKeyFn sign_for_key, SignAssetLockFn sign_asset_lock) + : m_sign_for_key(std::move(sign_for_key)), m_sign_asset_lock(std::move(sign_asset_lock)) + { + } + + bool SignForKey(uint32_t key_id, rust::Slice signable, + rust::Vec& sig_out) const + { + if (!m_sign_for_key) return false; + std::vector signature; + // Called from Rust frames: a C++ exception must not unwind through + // them (unsupported by cxx), so a throwing signer reads as a refusal. + try { + if (!m_sign_for_key(key_id, std::span(signable.data(), signable.size()), + signature)) { + return false; + } + } catch (...) { + return false; + } + Copy(signature, sig_out); + return true; + } + + bool SignAssetLockSighash(const std::array& sighash, + rust::Vec& sig_out) const + { + if (!m_sign_asset_lock) return false; + std::vector signature; + try { + if (!m_sign_asset_lock(sighash, signature)) return false; + } catch (...) { + return false; + } + Copy(signature, sig_out); + return true; + } + +private: + static void Copy(const std::vector& from, rust::Vec& to) + { + to.clear(); + to.reserve(from.size()); + for (uint8_t byte : from) { + to.push_back(byte); + } + } + + SignForKeyFn m_sign_for_key; + SignAssetLockFn m_sign_asset_lock; +}; + +} // namespace platform_ffi + +#endif // DASH_PLATFORM_CXX_SIGNER_H diff --git a/packages/rs-platform-cxx/scripts/cxx-smoke.sh b/packages/rs-platform-cxx/scripts/cxx-smoke.sh new file mode 100755 index 00000000000..2ab6941fe95 --- /dev/null +++ b/packages/rs-platform-cxx/scripts/cxx-smoke.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Builds the crate, then compiles and runs tests/cxx_smoke.cc against the +# staged headers and the static archive exactly as an embedder would. +set -euo pipefail + +package_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +workspace_dir="$(cd "${package_dir}/../.." && pwd)" +target_dir="${CARGO_TARGET_DIR:-${workspace_dir}/target}" +profile="${PROFILE:-debug}" +cxx="${CXX:-c++}" + +if [[ "${profile}" == "release" ]]; then + cargo build -p dash-platform-cxx --locked --release +else + cargo build -p dash-platform-cxx --locked +fi + +artifact_dir="${target_dir}/${profile}" +# dash-sdk's TLS stack reads the system trust store through +# rustls-native-certs: Security.framework on macOS, nothing extra on Linux. +system_libs=(-lpthread -lm) +case "$(uname -s)" in + Darwin) system_libs+=(-framework CoreFoundation -framework Security) ;; + Linux) system_libs+=(-ldl) ;; +esac + +out="$(mktemp -d "${TMPDIR:-/tmp}/dash-platform-cxx.XXXXXX")" +trap 'rm -rf "${out}"' EXIT + +"${cxx}" -std=c++20 -I"${artifact_dir}/include" \ + "${package_dir}/tests/cxx_smoke.cc" \ + "${artifact_dir}/libdash_platform_cxx.a" \ + "${system_libs[@]}" -o "${out}/cxx_smoke" +"${out}/cxx_smoke" +echo "cxx_smoke: ok" diff --git a/packages/rs-platform-cxx/src/builders.rs b/packages/rs-platform-cxx/src/builders.rs new file mode 100644 index 00000000000..3f13c34e4ac --- /dev/null +++ b/packages/rs-platform-cxx/src/builders.rs @@ -0,0 +1,662 @@ +// 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. + +//! State-transition assembly on the embedder's thread: dpp builds and signs +//! the transitions, this module only turns bridge inputs into dpp inputs +//! and assembles the three DashPay / DPNS documents `dash-sdk` does not yet +//! expose as pure functions (its `register_dpns_name` draws the preorder +//! salt itself, which a crash-safe flow cannot use, and it has no profile +//! helper). Contact requests go through `Sdk::create_contact_request`, so +//! the mint-side key policy, the DIP-15 size checks and the encryption are +//! the SDK's. +//! +//! The dpp builders are async only in signature; they are driven by +//! `futures::executor::block_on`, so no runtime is entered and the signer +//! is called synchronously on the calling thread. + +use std::collections::BTreeMap; + +use dash_sdk::dpp::dashcore::consensus::Decodable; +use dash_sdk::dpp::dashcore::secp256k1::rand::{rngs::StdRng, Rng, SeedableRng}; +use dash_sdk::dpp::dashcore::secp256k1::PublicKey; +use dash_sdk::dpp::dashcore::{InstantLock, OutPoint, Transaction}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dash_sdk::dpp::data_contract::DataContract; +use dash_sdk::dpp::document::{Document, DocumentV0, DocumentV0Getters, INITIAL_REVISION}; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; +use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; +use dash_sdk::dpp::identity::v0::IdentityV0; +use dash_sdk::dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::native_bls::NativeBlsModule; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::prelude::{AssetLockProof, Identifier}; +use dash_sdk::dpp::serialization::PlatformSerializable; +use dash_sdk::dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +use dash_sdk::dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; +use dash_sdk::dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; +use dash_sdk::dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dash_sdk::dpp::state_transition::batch_transition::BatchTransition; +use dash_sdk::dpp::state_transition::identity_create_transition::methods::IdentityCreateTransitionMethodsV0; +use dash_sdk::dpp::state_transition::identity_create_transition::IdentityCreateTransition; +use dash_sdk::dpp::state_transition::StateTransition; +use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use dash_sdk::dpp::util::hash::{hash_double, hash_single}; +use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::dashpay::{ContactRequestInput, EcdhProvider, RecipientIdentity}; +use dash_sdk::Sdk; +use futures::executor::block_on; +use zeroize::Zeroizing; + +use crate::ffi; +use crate::helpers::compact_xpub_to_bytes; +use crate::signer::{AssetLockSigner, BytesSigner, SignerCallbacks}; + +/// Both are consensus-bounded Core messages; anything larger than a block +/// is not one. +const MAX_CORE_MESSAGE_BYTES: usize = 2 * 1024 * 1024; + +/// Fresh document entropy. Drawn per build and never persisted: a rebuilt +/// transition gets a new id (from protocol version 14 the id also commits +/// to the identity contract nonce), so the unique indexes, not byte +/// identity, protect against duplicates. +pub fn fresh_entropy() -> [u8; 32] { + StdRng::from_entropy().gen() +} + +fn contract_bounds(bounds: &ffi::ContractBounds) -> Result, String> { + let id = Identifier::from(bounds.contract_id); + Ok(match bounds.kind { + ffi::BoundsKind::NoBounds => None, + ffi::BoundsKind::SingleContract => Some(ContractBounds::SingleContract { id }), + ffi::BoundsKind::SingleContractDocumentType => { + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name: bounds.document_type.clone(), + }) + } + ffi::BoundsKind::ContractGroup => Some(ContractBounds::ContractGroup { id }), + other => return Err(format!("unknown contract bounds kind {}", other.repr)), + }) +} + +/// A dpp key from the bridge form the embedder read back from +/// `get_identity`. +pub fn identity_key(key: &ffi::IdentityKey) -> Result { + Ok(IdentityPublicKeyV0 { + id: key.id, + purpose: Purpose::try_from(key.purpose) + .map_err(|e| format!("key {}: bad purpose: {e}", key.id))?, + security_level: SecurityLevel::try_from(key.security_level) + .map_err(|e| format!("key {}: bad security level: {e}", key.id))?, + contract_bounds: contract_bounds(&key.bounds)?, + key_type: KeyType::try_from(key.key_type) + .map_err(|e| format!("key {}: bad key type: {e}", key.id))?, + read_only: key.read_only, + data: key.data.clone().into(), + disabled_at: (key.disabled_at != 0).then_some(key.disabled_at), + } + .into()) +} + +/// A dpp identity from the bridge form; only the id and keys matter to the +/// builders. +pub fn identity(identity: &ffi::Identity) -> Result { + let public_keys = identity + .keys + .iter() + .map(|key| identity_key(key).map(|key| (key.id(), key))) + .collect::, _>>()?; + Ok(IdentityV0 { + id: Identifier::from(identity.id), + public_keys, + balance: identity.balance, + revision: identity.revision, + } + .into()) +} + +fn system_contract( + contract: SystemDataContract, + version: &PlatformVersion, +) -> Result { + load_system_data_contract(contract, version) + .map_err(|e| format!("unable to load the {contract:?} contract: {e}")) +} + +/// Serializes a signed transition. `object_id` names the identity or +/// document it creates or replaces. +fn built(state_transition: &StateTransition, object_id: Identifier) -> Result { + let bytes = state_transition + .serialize_to_bytes() + .map_err(|e| format!("unable to serialize the state transition: {e}"))?; + Ok(ffi::Built { + hash: hash_single(&bytes), + bytes, + object_id: object_id.to_buffer(), + }) +} + +/// A document with the given properties and no system fields: its id is +/// set from the entropy and nonce when the create transition is built (see +/// [`build_system_document`]), and Drive fills the timestamps. +fn new_document(owner: Identifier, properties: BTreeMap) -> Document { + Document::V0(DocumentV0 { + owner_id: owner, + properties, + ..Default::default() + }) +} + +/// The one document transition of a batch built here. +fn document_id(state_transition: &StateTransition) -> Result { + let StateTransition::Batch(batch) = state_transition else { + return Err("expected a batch transition".to_string()); + }; + match batch.first_transition() { + Some(BatchedTransitionRef::Document(transition)) => Ok(transition.get_id()), + _ => Err("the batch carries no document transition".to_string()), + } +} + +enum Kind { + Create { entropy: [u8; 32] }, + Replace, +} + +/// Builds and signs a single-document batch transition over a compiled-in +/// system contract's document type. The properties are sanitized for the +/// document type first, and a create gets the id derived from its entropy +/// and nonce at `version`, as `dash-sdk`'s put-document path does: up to +/// protocol version 13 dpp sends the document's id as it is (Drive +/// recomputes it from the entropy alone and refuses a mismatch), from 14 it +/// derives the id itself and this only anticipates it. +#[allow(clippy::too_many_arguments)] +fn build_system_document( + version: &PlatformVersion, + contract: SystemDataContract, + type_name: &str, + mut document: Document, + nonce: u64, + kind: Kind, + key: &ffi::IdentityKey, + signer: &dyn SignerCallbacks, +) -> Result { + let contract = system_contract(contract, version)?; + let document_type = contract + .document_type_for_name(type_name) + .map_err(|e| e.to_string())?; + // The contract at `version` may predate a property (DashPay's payment + // addresses arrive with protocol version 14); Drive would refuse the + // document after charging for it. + if let Some(unknown) = document + .properties() + .keys() + .find(|name| !document_type.properties().contains_key(*name)) + { + return Err(format!( + "the {type_name} document type has no property {unknown} at protocol version {}", + version.protocol_version + )); + } + document_type.sanitize_document_properties(document.properties_mut()); + let identity_key = identity_key(key)?; + let signer = BytesSigner(signer); + let state_transition = match kind { + Kind::Create { entropy } => { + document + .set_id_for_creation(document_type, &entropy, nonce, version) + .map_err(|e| format!("unable to derive the document id: {e}"))?; + block_on( + BatchTransition::new_document_creation_transition_from_document( + document, + document_type, + entropy, + &identity_key, + nonce, + 0, + None, + &signer, + version, + None, + ), + ) + } + Kind::Replace => block_on( + BatchTransition::new_document_replacement_transition_from_document( + document, + document_type, + &identity_key, + nonce, + 0, + None, + &signer, + version, + None, + ), + ), + } + .map_err(|e| { + format!( + "unable to build the {} transition: {e}", + document_type.name() + ) + })?; + let id = document_id(&state_transition)?; + built(&state_transition, id) +} + +/// The DPNS preorder commitment: double SHA256 of `salt ‖ normalized label +/// ‖ ".dash"`, as `register_dpns_name` computes it. +pub fn salted_domain_hash(normalized_label: &str, salt: &[u8; 32]) -> [u8; 32] { + let mut buffer = salt.to_vec(); + buffer.extend_from_slice(normalized_label.as_bytes()); + buffer.extend_from_slice(b".dash"); + hash_double(buffer) +} + +/// The DPNS `preorder` document `register_dpns_name` would build for +/// `label` and `salt`. +pub fn dpns_preorder_document(owner: Identifier, label: &str, salt: &[u8; 32]) -> Document { + let normalized_label = convert_to_homograph_safe_chars(label); + new_document( + owner, + BTreeMap::from([( + "saltedDomainHash".to_string(), + Value::Bytes32(salted_domain_hash(&normalized_label, salt)), + )]), + ) +} + +/// The DPNS `domain` document `register_dpns_name` would build for `label` +/// and `salt`, under the "dash" parent, pointing at `owner`. +pub fn dpns_domain_document(owner: Identifier, label: &str, salt: &[u8; 32]) -> Document { + new_document( + owner, + BTreeMap::from([ + ( + "parentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ( + "normalizedParentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ("label".to_string(), Value::Text(label.to_string())), + ( + "normalizedLabel".to_string(), + Value::Text(convert_to_homograph_safe_chars(label)), + ), + ("preorderSalt".to_string(), Value::Bytes32(*salt)), + ( + "records".to_string(), + Value::Map(vec![( + Value::Text("identity".to_string()), + Value::Identifier(owner.to_buffer()), + )]), + ), + ( + "subdomainRules".to_string(), + Value::Map(vec![( + Value::Text("allowSubdomains".to_string()), + Value::Bool(false), + )]), + ), + ]), + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_dpns_preorder( + version: &PlatformVersion, + owner: [u8; 32], + nonce: u64, + label: &str, + salt: &[u8; 32], + entropy: [u8; 32], + key: &ffi::IdentityKey, + signer: &dyn SignerCallbacks, +) -> Result { + build_system_document( + version, + SystemDataContract::DPNS, + "preorder", + dpns_preorder_document(Identifier::from(owner), label, salt), + nonce, + Kind::Create { entropy }, + key, + signer, + ) +} + +/// The contested-name prefund is attached by dpp from the domain type's +/// contested unique index; nothing here decides whether a name is contested. +#[allow(clippy::too_many_arguments)] +pub fn build_dpns_domain( + version: &PlatformVersion, + owner: [u8; 32], + nonce: u64, + label: &str, + salt: &[u8; 32], + entropy: [u8; 32], + key: &ffi::IdentityKey, + signer: &dyn SignerCallbacks, +) -> Result { + build_system_document( + version, + SystemDataContract::DPNS, + "domain", + dpns_domain_document(Identifier::from(owner), label, salt), + nonce, + Kind::Create { entropy }, + key, + signer, + ) +} + +/// A DashPay `profile`: created when `existing` has no document id, else +/// replaced at its revision + 1. A replace transition carries the whole +/// document, so the fields the embedder does not edit (avatar, payment +/// addresses) are carried over from `existing` as `get_profile` read them; +/// an empty edited string leaves the field out. +#[allow(clippy::too_many_arguments)] +pub fn build_profile( + version: &PlatformVersion, + owner: [u8; 32], + nonce: u64, + existing: &ffi::Profile, + input: &ffi::ProfileInput, + entropy: [u8; 32], + key: &ffi::IdentityKey, + signer: &dyn SignerCallbacks, +) -> Result { + let texts = [ + ("displayName", &input.display_name), + ("publicMessage", &input.public_message), + ("avatarUrl", &existing.avatar_url), + ] + .into_iter() + .filter(|(_, text)| !text.is_empty()) + .map(|(name, text)| (name.to_string(), Value::Text(text.clone()))); + let bytes = [ + ("avatarHash", &existing.avatar_hash), + ("avatarFingerprint", &existing.avatar_fingerprint), + ("corePaymentAddress", &existing.core_payment_address), + ("platformPaymentAddress", &existing.platform_payment_address), + ("shieldedAddress", &existing.shielded_address), + ] + .into_iter() + .filter(|(_, bytes)| !bytes.is_empty()) + .map(|(name, bytes)| (name.to_string(), Value::Bytes(bytes.clone()))); + let properties = texts.chain(bytes).collect(); + let owner = Identifier::from(owner); + if existing.document_id == [0u8; 32] { + return build_system_document( + version, + SystemDataContract::Dashpay, + "profile", + new_document(owner, properties), + nonce, + Kind::Create { entropy }, + key, + signer, + ); + } + if existing.owner != owner.to_buffer() { + return Err("the profile to replace belongs to another identity".to_string()); + } + if existing.revision < INITIAL_REVISION { + return Err("the profile to replace carries no revision".to_string()); + } + let document = Document::V0(DocumentV0 { + id: Identifier::from(existing.document_id), + owner_id: owner, + properties, + revision: Some(existing.revision + 1), + ..Default::default() + }); + build_system_document( + version, + SystemDataContract::Dashpay, + "profile", + document, + nonce, + Kind::Replace, + key, + signer, + ) +} + +/// A DashPay `contactRequest`, minted by `Sdk::create_contact_request` with +/// the embedder's ECDH secret (`EcdhProvider::ClientSide`) and continued +/// into the batch transition exactly as `send_contact_request` does. The +/// contract comes from the context provider, so nothing touches the +/// network. The SDK's own version only picks the contract it mints with; +/// the transition is built under `version`, the one a verified read has +/// shown the network to run. +#[allow(clippy::too_many_arguments)] +pub fn build_contact_request( + sdk: &Sdk, + version: &PlatformVersion, + sender: &ffi::Identity, + recipient: &ffi::Identity, + nonce: u64, + input: &ffi::ContactRequestInput, + key: &ffi::IdentityKey, + signer: &dyn SignerCallbacks, +) -> Result { + let expected_recipient_pubkey = PublicKey::from_slice(&input.recipient_pubkey) + .map_err(|e| format!("bad recipient public key: {e}"))?; + let shared_secret = Zeroizing::new(input.shared_secret); + let xpub = Zeroizing::new(compact_xpub_to_bytes(&input.compact_xpub)); + + let contact_request = ContactRequestInput { + sender_identity: identity(sender)?, + recipient: RecipientIdentity::Identity(identity(recipient)?), + sender_key_index: input.sender_key_index, + recipient_key_index: input.recipient_key_index, + account_reference: input.account_reference, + account_label: (!input.account_label.is_empty()).then(|| input.account_label.clone()), + auto_accept_proof: None, + }; + // The SDK-side ECDH variant is never used; a never-called `fn` satisfies + // its type parameters. + type UnusedSdkSideEcdh = fn( + &IdentityPublicKey, + u32, + ) -> std::future::Ready< + Result, + >; + let ecdh: EcdhProvider = EcdhProvider::ClientSide { + // The secret was derived against `recipient_pubkey`; the SDK must + // have selected that same key from the recipient identity, or the + // request would encrypt to a key the recipient cannot use. + get_shared_secret: move |peer: &PublicKey| { + let matches = *peer == expected_recipient_pubkey; + async move { + if !matches { + return Err(dash_sdk::Error::Generic( + "the recipient key at recipient_key_index is not the key the shared \ + secret was derived against" + .to_string(), + )); + } + // The SDK takes the secret by value and drops its copy after + // encrypting; only this crate's copies are zeroized. + Ok(*shared_secret) + } + }, + }; + let result = block_on( + sdk.create_contact_request(contact_request, ecdh, |_account| { + let xpub = xpub.clone(); + async move { Ok(xpub.to_vec()) } + }), + ) + .map_err(|e| format!("unable to create the contact request: {e}"))?; + + let document = Document::V0(DocumentV0 { + id: result.id, + owner_id: result.owner_id, + properties: result.properties, + ..Default::default() + }); + build_system_document( + version, + SystemDataContract::Dashpay, + "contactRequest", + document, + nonce, + Kind::Create { + entropy: result.entropy.0, + }, + key, + signer, + ) +} + +fn asset_lock_proof(input: &ffi::AssetLockProofInput) -> Result { + if input.is_instant { + if input.transaction.len() > MAX_CORE_MESSAGE_BYTES + || input.instant_lock.len() > MAX_CORE_MESSAGE_BYTES + { + return Err("the asset lock transaction or instant lock exceeds 2 MiB".to_string()); + } + let transaction = Transaction::consensus_decode(&mut input.transaction.as_slice()) + .map_err(|e| format!("bad asset lock transaction: {e}"))?; + let instant_lock = InstantLock::consensus_decode(&mut input.instant_lock.as_slice()) + .map_err(|e| format!("bad instant lock: {e}"))?; + return Ok(AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + transaction, + input.output_index, + ))); + } + let out_point = OutPoint::consensus_decode(&mut input.out_point.as_slice()) + .map_err(|e| format!("bad asset lock outpoint: {e}"))?; + Ok(AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: input.core_chain_locked_height, + out_point, + })) +} + +/// An `IdentityCreateTransition` through dpp's +/// `try_from_identity_with_signers`: every registered key signs the +/// transition through `SignForKey`, the asset-lock outpoint key through +/// `SignAssetLockSighash`. The identity id is derived from the asset lock. +pub fn build_identity_create( + version: &PlatformVersion, + proof: &ffi::AssetLockProofInput, + keys: &[ffi::NewIdentityKey], + signer: &dyn SignerCallbacks, +) -> Result { + if keys.is_empty() { + return Err("an identity needs at least one key".to_string()); + } + let proof = asset_lock_proof(proof)?; + let identity_id = proof + .create_identifier() + .map_err(|e| format!("unable to derive the identity id: {e}"))?; + let mut public_keys = BTreeMap::new(); + for key in keys { + let identity_key = identity_key(&ffi::IdentityKey { + id: key.id, + purpose: key.purpose, + security_level: key.security_level, + key_type: KeyType::ECDSA_SECP256K1 as u8, + read_only: false, + data: key.pubkey.to_vec(), + disabled_at: 0, + bounds: key.bounds.clone(), + })?; + if public_keys.insert(key.id, identity_key).is_some() { + return Err(format!("duplicate identity key id {}", key.id)); + } + } + let identity: Identity = IdentityV0 { + id: identity_id, + public_keys, + balance: 0, + revision: 0, + } + .into(); + let state_transition = block_on(IdentityCreateTransition::try_from_identity_with_signers( + &identity, + proof, + &DerivationPath::master(), + &BytesSigner(signer), + &AssetLockSigner(signer), + &NativeBlsModule, + 0, + version, + )) + .map_err(|e| format!("unable to build the identity create transition: {e}"))?; + built(&state_transition, identity_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn salted_domain_hash_matches_register_dpns_name() { + // The SDK hashes `salt ‖ normalized_label ‖ ".dash"` with sha256d. + let salt = [0x11u8; 32]; + let mut preimage = salt.to_vec(); + preimage.extend_from_slice(b"a11ce.dash"); + assert_eq!( + salted_domain_hash("a11ce", &salt), + dash_sdk::dpp::util::hash::hash_double(preimage) + ); + } + + #[test] + fn bounds_and_keys_round_trip() { + let key = ffi::IdentityKey { + id: 2, + purpose: Purpose::ENCRYPTION as u8, + security_level: SecurityLevel::MEDIUM as u8, + key_type: KeyType::ECDSA_SECP256K1 as u8, + data: vec![2u8; 33], + bounds: ffi::ContractBounds { + kind: ffi::BoundsKind::SingleContractDocumentType, + contract_id: SystemDataContract::Dashpay.id().to_buffer(), + document_type: "contactRequest".to_string(), + }, + ..Default::default() + }; + let dpp_key = identity_key(&key).expect("key"); + assert_eq!( + dpp_key.contract_bounds(), + Some(&ContractBounds::SingleContractDocumentType { + id: SystemDataContract::Dashpay.id(), + document_type_name: "contactRequest".to_string(), + }) + ); + assert!(identity_key(&ffi::IdentityKey { + purpose: 200, + ..key.clone() + }) + .is_err()); + assert!(identity_key(&ffi::IdentityKey { + bounds: ffi::ContractBounds { + kind: ffi::BoundsKind { repr: 9 }, + ..Default::default() + }, + ..key + }) + .is_err()); + } + + #[test] + fn entropy_is_fresh_per_call() { + assert_ne!(fresh_entropy(), fresh_entropy()); + } +} diff --git a/packages/rs-platform-cxx/src/client.rs b/packages/rs-platform-cxx/src/client.rs new file mode 100644 index 00000000000..45c9aae7ed5 --- /dev/null +++ b/packages/rs-platform-cxx/src/client.rs @@ -0,0 +1,918 @@ +// 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. + +//! The embedder's handle on `dash-sdk`: the runtime, the provider, the +//! `Sdk` built lazily from the endpoint set the embedder pushes (and dropped +//! while that set is empty), and the shell's own Platform-height watermark +//! and verified protocol version, which outlive any one `Sdk`. + +use std::collections::HashSet; +use std::future::Future; +use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use dash_sdk::dapi_client::transport::{ProxyEndpoint, Socks5Auth, Socks5Proxy}; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::version::{PlatformVersion, LATEST_VERSION}; +use dash_sdk::sdk::{min_protocol_version, Address, AddressList}; +use dash_sdk::{RequestSettings, Sdk, SdkBuilder}; + +use crate::ffi::{self, Status}; +use crate::provider::LocalContextProvider; +use crate::runtime::{RunError, Runtime}; +use crate::sync::{read, write}; + +/// Per-request deadline. The SDK may retry across endpoints inside it and +/// spend more than `(REQUEST_TIMEOUT + CONNECT_TIMEOUT) * RETRIES` in total. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +/// Budget to open a TLS connection to one evonode. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// The same through the proxy: building a Tor circuit plus the SOCKS5 +/// handshake plus TLS (Dash Core allows 20 s for each SOCKS5 reply). +const PROXIED_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +/// Retries per logical request; each may hit another endpoint as the SDK +/// bans failing ones. +const RETRIES: usize = 2; +/// Largest gRPC response the SDK decodes (tonic's default). +const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024; +/// The SDK's signed-time window: a verified response whose signed time is +/// further than this from the local clock is stale or replayed. +const TIME_TOLERANCE: Duration = Duration::from_secs(10 * 60); +/// Platform blocks a verified response may trail the highest verified +/// height before the shell refuses it. The SDK's own watermark (default +/// tolerance 1, which would ban honest evonodes one block apart) is off. +pub const HEIGHT_TOLERANCE: u64 = 3; +/// The `Unavailable` reason after `shutdown`. +const SHUT_DOWN: &str = "platform client is shut down"; + +pub struct Client { + network: Network, + tenderdash_chain_id: String, + /// Fixed for the client's lifetime: the embedder's proxy settings do + /// not change without a restart, so no SDK ever runs without it. + proxy: Option, + provider: Arc, + runtime: Runtime, + sdk: RwLock>, + /// The endpoint set the embedder last pushed; the SDK's live list is + /// diffed against it, so ban state of retained entries is untouched. + endpoints: RwLock>, + /// Highest Platform height verified so far (0 = none yet). + last_seen_height: AtomicU64, + /// Highest protocol version a response the shell accepted has carried + /// (0 = none yet), known to this build or not. Kept apart from the + /// SDK's ratchet, which runs inside proof verification before the + /// shell's chain-id check. + verified_protocol_version: AtomicU32, +} + +impl Client { + pub fn new(cfg: &ffi::Config) -> Result { + let network = match cfg.network { + 0 => Network::Mainnet, + 1 => Network::Testnet, + 2 => Network::Devnet, + 3 => Network::Regtest, + other => return Err(format!("unknown network {other}")), + }; + if cfg.tenderdash_chain_id.is_empty() { + return Err("the tenderdash chain id must not be empty".to_string()); + } + Ok(Client { + network, + tenderdash_chain_id: cfg.tenderdash_chain_id.clone(), + proxy: proxy(&cfg.proxy)?, + provider: Arc::new(LocalContextProvider::new(cfg.platform_llmq_type)), + runtime: Runtime::new()?, + sdk: RwLock::new(None), + endpoints: RwLock::new(HashSet::new()), + last_seen_height: AtomicU64::new(0), + verified_protocol_version: AtomicU32::new(0), + }) + } + + pub fn provider(&self) -> &Arc { + &self.provider + } + + pub fn tenderdash_chain_id(&self) -> &str { + &self.tenderdash_chain_id + } + + /// Replaces the evonode endpoint set. Only `https` endpoints are + /// accepted: the address list takes any scheme and would dial `http` in + /// the clear. The host must be an IP address, or an onion name when a + /// proxy is configured: nothing is ever resolved locally, and an onion + /// name is reachable only through the proxy. + /// + /// Removing an address from the SDK's list only stops new requests + /// going to it: the DAPI client keeps a pooled channel per endpoint it + /// has talked to, and the channel holds its connection open for as long + /// as the pool does. The pool is private to the DAPI client, so the only + /// way to close those connections is to drop the client. Hence: + /// - an empty set empties the shared address list, so a request still + /// in flight makes no further attempt, and drops the SDK, closing + /// every connection (`DapiClient::new` would panic on an empty list + /// anyway); the next non-empty set builds a new one; + /// - a set that removes entries builds a new SDK over the same, updated + /// address list, so the connections to removed nodes close while the + /// retained entries keep their ban state; retained nodes reconnect on + /// their next request; + /// - a set that only adds entries updates the list in place. + /// + /// The provider, the height watermark and the verified protocol version + /// belong to the client and survive every rebuild. + pub fn set_endpoints(&self, https_uris: &[String]) -> Result<(), String> { + let wanted = https_uris + .iter() + .map(|uri| { + let address = + Address::from_str(uri).map_err(|e| format!("bad evonode endpoint: {e}"))?; + if address.uri().scheme_str() != Some("https") { + return Err(format!("bad evonode endpoint: {uri} is not https")); + } + self.check_host(uri, address.uri().host().unwrap_or_default())?; + Ok(address) + }) + .collect::, String>>()?; + let mut sdk = write(&self.sdk); + let mut endpoints = write(&self.endpoints); + let retired = match sdk.as_ref() { + None if wanted.is_empty() => None, + None => { + *sdk = Some(self.build_sdk(wanted.iter().cloned().collect())?); + None + } + Some(current) if wanted.is_empty() => { + // An in-flight request holds a clone of the SDK and picks + // an address from this shared list for every attempt; + // emptying it (iterating an `AddressList` takes its + // entries) stops that request dialling the old nodes again. + current.address_list().clone().into_iter().for_each(drop); + sdk.take() + } + Some(current) => { + let mut live = current.address_list().clone(); + for added in wanted.difference(&endpoints) { + live.add(added.clone()); + } + if endpoints.is_subset(&wanted) { + None + } else { + for gone in endpoints.difference(&wanted) { + live.remove(gone); + } + sdk.replace(self.build_sdk(live)?) + } + } + }; + // Recorded only once the SDK follows the set. The live list is + // shared with the current SDK, so after a failed rebuild it already + // routes by the new set, but the removed nodes' channels are still + // pooled; diffing the next call against the old set rebuilds then. + *endpoints = wanted; + drop(endpoints); + drop(sdk); + if let Some(retired) = retired { + self.retire(retired); + } + Ok(()) + } + + fn check_host(&self, uri: &str, host: &str) -> Result<(), String> { + let unbracketed = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host); + if unbracketed.parse::().is_ok() { + return Ok(()); + } + if host.ends_with(".onion") { + return match self.proxy { + Some(_) => Ok(()), + None => Err(format!( + "bad evonode endpoint: {uri} is an onion name and no proxy is configured" + )), + }; + } + Err(format!( + "bad evonode endpoint: {uri} is neither an IP address nor an onion name" + )) + } + + /// A network SDK over `addresses`. Once a read has been accepted it is + /// seeded at the verified protocol version (capped at the latest this + /// build knows), so a rebuilt SDK does not fall back to the network + /// floor. + fn build_sdk(&self, addresses: AddressList) -> Result { + let mut builder = self.configure(SdkBuilder::new(addresses)); + let verified = self.verified_protocol_version.load(Ordering::Acquire); + if verified != 0 { + let seed = verified.clamp(self.floor_version().protocol_version, LATEST_VERSION); + builder = builder.with_initial_version( + PlatformVersion::get(seed) + .map_err(|e| format!("unknown verified protocol version: {e}"))?, + ); + } + builder + .build() + .map_err(|e| format!("unable to build the Platform SDK: {e}")) + } + + /// Drops `sdk`, and with it the connection pool once no in-flight + /// request still holds a clone (at most until that request's deadline). + /// The pool is dropped inside the runtime context: tonic's channels + /// expect a reactor on drop. + fn retire(&self, sdk: Sdk) { + match self.runtime.handle() { + Some(handle) => { + let _entered = handle.enter(); + drop(sdk); + } + None => drop(sdk), + } + } + + /// The shell's SDK policy on any builder: proofs on, this provider, + /// the proxy, the request budget, the signed-time window, and the SDK's + /// own height watermark off (the shell keeps its own, see + /// [`HEIGHT_TOLERANCE`]). + fn configure(&self, builder: SdkBuilder) -> SdkBuilder { + let (builder, connect_timeout) = match &self.proxy { + Some(proxy) => (builder.with_proxy(proxy.clone()), PROXIED_CONNECT_TIMEOUT), + None => (builder, CONNECT_TIMEOUT), + }; + builder + .with_network(self.network) + .with_proofs(true) + .with_context_provider(Arc::clone(&self.provider)) + .with_settings(RequestSettings { + connect_timeout: Some(connect_timeout), + timeout: Some(REQUEST_TIMEOUT), + retries: Some(RETRIES), + ban_failed_address: Some(true), + max_decoding_message_size: Some(MAX_RESPONSE_BYTES), + }) + .with_time_tolerance(Some(TIME_TOLERANCE.as_millis() as u64)) + .with_height_tolerance(None) + } + + /// A mock-transport SDK builder under the same policy as the network + /// SDK, for tests that replay recorded responses through this client. + #[cfg(feature = "mocks")] + pub fn mock_sdk_builder(&self) -> SdkBuilder { + self.configure(SdkBuilder::new_mock()) + } + + /// Installs a ready-made SDK (tests use a mock one) in place of the + /// lazily built network SDK. + #[cfg(feature = "mocks")] + pub fn set_sdk(&self, sdk: Sdk) { + *write(&self.sdk) = Some(sdk); + } + + /// The SDK instance, once endpoints have been pushed. + pub fn sdk(&self) -> Result { + read(&self.sdk) + .clone() + .ok_or_else(|| "no evonode endpoints".to_string()) + } + + /// Whether a request can be dispatched at all: the client is not shut + /// down and endpoints have been pushed. `Unavailable` otherwise. + pub fn check_ready(&self) -> Result<(), Status> { + if self.runtime.handle().is_none() { + return Err(Status::unavailable(SHUT_DOWN)); + } + if read(&self.sdk).is_none() { + return Err(Status::unavailable("no evonode endpoints")); + } + Ok(()) + } + + /// [`Self::check_ready`] plus the trust anchor a proved read needs: a + /// local ChainLock height. The provider refuses every proof without one + /// anyway; not dispatching keeps the SDK from banning honest nodes for + /// the embedder's own missing state. + pub fn check_ready_for_proofs(&self) -> Result<(), Status> { + self.check_ready()?; + if self.provider.local_core_chain_locked_height() == 0 { + return Err(Status::unavailable( + "no local ChainLock anchor pushed yet; proved reads are not dispatched", + )); + } + Ok(()) + } + + /// The SDK's view of the protocol version: the network floor until a + /// verified response ratchets it. The SDK ratchets inside proof + /// verification, before the shell's chain-id check, so a validly signed + /// proof from another chain can move it; nothing the shell decides + /// reads it. + pub fn platform_version(&self) -> &'static PlatformVersion { + match read(&self.sdk).as_ref() { + Some(sdk) => sdk.version(), + None => self.floor_version(), + } + } + + fn floor_version(&self) -> &'static PlatformVersion { + PlatformVersion::get(min_protocol_version(self.network)) + .expect("the network floor is a known protocol version") + } + + /// Records the protocol version of a response the shell accepted (after + /// the chain-id check and the height watermark). Monotonic; the signed + /// metadata covers it, so it is trusted as far as the quorum is. + pub fn observe_protocol_version(&self, version: u32) { + self.verified_protocol_version + .fetch_max(version, Ordering::AcqRel); + } + + /// The protocol version a state transition must be built under: the + /// highest one a response the shell accepted has shown the network to + /// run. Before that only the network floor is known, and a transition + /// built there can differ from what the network expects (from protocol + /// version 14 a document id commits to the identity contract nonce; the + /// contested prefund changed in the same version), so building is + /// refused until a read has verified the version. The floor equals the + /// latest known version on devnets, where nothing higher can be learned. + /// Once the network has been seen to run a version this build does not + /// know, building is refused as well: the reads signal + /// `UnsupportedProtocolVersion` and the embedder must update. The + /// version only moves up; a network upgrade is seen on the next accepted + /// read, which the embedder makes (the nonce) right before building. + pub fn verified_platform_version(&self) -> Result<&'static PlatformVersion, String> { + let floor = self.floor_version(); + match self.verified_protocol_version.load(Ordering::Acquire) { + 0 if floor.protocol_version == LATEST_VERSION => Ok(floor), + 0 => Err(format!( + "no verified read yet: the network's protocol version is unknown (the {} floor \ + is {}, this build knows {LATEST_VERSION})", + self.network, floor.protocol_version + )), + verified if verified > LATEST_VERSION => Err(format!( + "the network runs protocol version {verified}, this build knows up to \ + {LATEST_VERSION}; no transition can be built until it is updated" + )), + verified => PlatformVersion::get(verified.max(floor.protocol_version)) + .map_err(|e| format!("unknown verified protocol version: {e}")), + } + } + + /// Highest Platform height verified so far. + pub fn last_seen_height(&self) -> u64 { + self.last_seen_height.load(Ordering::Acquire) + } + + /// Advances the watermark to `height` and reports whether a response at + /// that height is fresh: at most [`HEIGHT_TOLERANCE`] blocks behind the + /// highest height seen, itself included. + pub fn observe_height(&self, height: u64) -> bool { + let previous = self.last_seen_height.fetch_max(height, Ordering::AcqRel); + let expected = previous.max(self.last_seen_height.load(Ordering::Acquire)); + !(expected > HEIGHT_TOLERANCE && height < expected - HEIGHT_TOLERANCE) + } + + /// Runs one SDK operation on the runtime, blocking the calling thread. + /// The SDK is cloned out of its lock so concurrent callers do not + /// serialize on it; the clone shares the address list and version + /// state. + pub fn run(&self, op: F) -> Result + where + F: FnOnce(Sdk) -> Fut, + Fut: Future + Send + 'static, + T: Send + 'static, + { + self.check_ready()?; + let sdk = self.sdk().map_err(Status::unavailable)?; + self.runtime.run(op(sdk)).map_err(|error| match error { + RunError::ShutDown => Status::unavailable(SHUT_DOWN), + RunError::Panicked(message) => Status::internal(message), + }) + } + + /// Aborts the in-flight request, cancels the SDK and stops the runtime + /// (bounded by the runtime's shutdown timeout). Reads afterwards return + /// `Unavailable`. Idempotent. + pub fn shutdown(&self) { + // The connection pool goes before the runtime itself. + let sdk = write(&self.sdk).take(); + if let Some(sdk) = sdk { + sdk.shutdown(); + self.retire(sdk); + } + self.runtime.shutdown(); + } +} + +/// The embedder's proxy setting as the DAPI client's. Anything malformed +/// is an error, so the embedder gets no client rather than one that +/// connects directly. +fn proxy(cfg: &ffi::Proxy) -> Result, String> { + let endpoint = match cfg.kind { + 0 if cfg.address.is_empty() => return Ok(None), + 0 => return Err("a proxy address is set without a proxy kind".to_string()), + 1 => ProxyEndpoint::Tcp(SocketAddr::from_str(&cfg.address).map_err(|_| { + format!( + "the proxy address {:?} is not a numeric ip:port", + cfg.address + ) + })?), + #[cfg(unix)] + 2 if !cfg.address.is_empty() => ProxyEndpoint::Unix(cfg.address.clone().into()), + #[cfg(unix)] + 2 => return Err("the proxy socket path is empty".to_string()), + other => return Err(format!("unsupported proxy kind {other}")), + }; + let auth = if cfg.isolate { + Socks5Auth::RandomPerConnection + } else { + Socks5Auth::None + }; + Ok(Some(Socks5Proxy { endpoint, auth })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn no_proxy() -> ffi::Proxy { + ffi::Proxy { + kind: 0, + address: String::new(), + isolate: false, + } + } + + fn config(proxy: ffi::Proxy) -> ffi::Config { + ffi::Config { + network: 1, + tenderdash_chain_id: "dash-testnet-51".to_string(), + platform_llmq_type: 106, + proxy, + } + } + + fn testnet_client() -> Client { + Client::new(&config(no_proxy())).expect("client") + } + + fn proxied_client() -> Client { + Client::new(&config(ffi::Proxy { + kind: 1, + address: "127.0.0.1:9050".to_string(), + isolate: true, + })) + .expect("client") + } + + #[test] + fn config_is_validated() { + assert!(Client::new(&ffi::Config { + network: 9, + ..config(no_proxy()) + }) + .is_err()); + assert!(Client::new(&ffi::Config { + tenderdash_chain_id: String::new(), + ..config(no_proxy()) + }) + .is_err()); + } + + #[test] + fn the_proxy_config_is_validated() { + let proxy = |kind: u8, address: &str, isolate: bool| { + super::proxy(&ffi::Proxy { + kind, + address: address.to_string(), + isolate, + }) + }; + assert_eq!(proxy(0, "", true), Ok(None)); + assert_eq!( + proxy(1, "127.0.0.1:9050", true), + Ok(Some(Socks5Proxy { + endpoint: ProxyEndpoint::Tcp("127.0.0.1:9050".parse().expect("address")), + auth: Socks5Auth::RandomPerConnection, + })) + ); + assert_eq!( + proxy(1, "[::1]:9050", false), + Ok(Some(Socks5Proxy { + endpoint: ProxyEndpoint::Tcp("[::1]:9050".parse().expect("address")), + auth: Socks5Auth::None, + })) + ); + #[cfg(unix)] + assert_eq!( + proxy(2, "/run/tor/socks", true), + Ok(Some(Socks5Proxy { + endpoint: ProxyEndpoint::Unix("/run/tor/socks".into()), + auth: Socks5Auth::RandomPerConnection, + })) + ); + // Fail closed: a proxy the shell cannot use is no client at all. + for (kind, address) in [ + (0, "127.0.0.1:9050"), + (1, "localhost:9050"), + (1, "127.0.0.1"), + (1, ""), + (2, ""), + (7, "127.0.0.1:9050"), + ] { + assert!(proxy(kind, address, true).is_err(), "{kind} {address:?}"); + assert!(Client::new(&config(ffi::Proxy { + kind, + address: address.to_string(), + isolate: true, + })) + .is_err()); + } + } + + /// The proxy reaches every SDK the client builds, with the longer + /// connect budget. + #[test] + fn a_proxied_client_builds_proxied_sdks() { + let connect_timeout = |client: &Client| { + client + .set_endpoints(&["https://1.1.1.1:443".to_string()]) + .expect("endpoint"); + let sdk = client.sdk().expect("built"); + sdk.query_settings().request_settings.connect_timeout + }; + let proxied = proxied_client(); + assert_eq!(connect_timeout(&proxied), Some(PROXIED_CONNECT_TIMEOUT)); + proxied.shutdown(); + let direct = testnet_client(); + assert_eq!(connect_timeout(&direct), Some(CONNECT_TIMEOUT)); + direct.shutdown(); + } + + /// A proxy that fails is an outage (`Unavailable`), not a node's + /// refusal, although the SDK neither retries it nor bans the node. + #[test] + fn a_failing_proxy_is_unavailable_and_bans_nothing() { + // A proxy that hangs up on every connection before answering. + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let address = listener.local_addr().expect("address"); + std::thread::spawn(move || { + for stream in listener.incoming() { + drop(stream); + } + }); + let client = Client::new(&config(ffi::Proxy { + kind: 1, + address: address.to_string(), + isolate: true, + })) + .expect("client"); + let endpoint = "https://127.0.0.1:1".to_string(); + client + .set_endpoints(std::slice::from_ref(&endpoint)) + .expect("endpoint"); + client.provider().set_local_core_chain_locked_height(1); + + let status = crate::ops::get_identity(&client, [1u8; 32]).status; + + assert_eq!( + status.kind, + ffi::StatusKind::Unavailable, + "{}", + status.message + ); + assert!( + status.message.contains("SOCKS5 proxy"), + "{}", + status.message + ); + let address: Address = endpoint.parse().expect("address"); + assert!(!client + .sdk() + .expect("sdk") + .address_list() + .is_banned(&address)); + client.shutdown(); + } + + /// Nothing is resolved locally, and an onion name needs the proxy. + #[test] + fn endpoint_hosts_are_addresses_or_proxied_onion_names() { + let onion = "https://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:443" + .to_string(); + let direct = testnet_client(); + assert!(direct + .set_endpoints(&["https://[2001:db8::1]:443".to_string()]) + .is_ok()); + assert!(direct.set_endpoints(std::slice::from_ref(&onion)).is_err()); + assert!(direct + .set_endpoints(&["https://evo.example:443".to_string()]) + .is_err()); + direct.shutdown(); + + let proxied = proxied_client(); + assert!(proxied.set_endpoints(std::slice::from_ref(&onion)).is_ok()); + assert!(proxied + .set_endpoints(&["https://evo.example:443".to_string()]) + .is_err()); + proxied.shutdown(); + } + + /// Tasks alive on the client's runtime: each pooled tonic channel keeps + /// one (its buffer worker, plus the connection once one is open) until + /// the pool drops its last clone. The pool itself is private to the + /// DAPI client, so this is how a test sees it go. + fn alive_tasks(client: &Client) -> usize { + client + .runtime + .handle() + .expect("runtime") + .metrics() + .num_alive_tasks() + } + + /// Waits up to a second for the runtime to reap finished tasks. + fn settled_tasks(client: &Client, expected: usize) -> usize { + let deadline = std::time::Instant::now() + Duration::from_secs(1); + loop { + let alive = alive_tasks(client); + if alive == expected || std::time::Instant::now() > deadline { + return alive; + } + std::thread::sleep(Duration::from_millis(10)); + } + } + + /// One proved read: every attempt fails to connect and bans its address, + /// so the retries walk the whole (two-entry) list and each endpoint ends + /// up with a pooled channel. + fn dial_every_endpoint(client: &Client) { + client.provider().set_local_core_chain_locked_height(1); + let status = crate::ops::get_identity(client, [1u8; 32]).status; + assert_eq!( + status.kind, + ffi::StatusKind::Unavailable, + "{}", + status.message + ); + } + + #[test] + fn an_empty_endpoint_set_drops_the_pooled_channels() { + let client = testnet_client(); + let uri = |port: u16| format!("https://127.0.0.1:{port}"); + let idle = alive_tasks(&client); + client.set_endpoints(&[uri(1), uri(2)]).expect("endpoints"); + dial_every_endpoint(&client); + assert!( + settled_tasks(&client, idle) > idle, + "the dialled endpoints keep pooled channels" + ); + client.observe_protocol_version(LATEST_VERSION); + client.observe_height(42); + + client.set_endpoints(&[]).expect("empty set"); + assert!( + client.sdk().is_err(), + "no SDK while there is nothing to talk to" + ); + assert_eq!( + settled_tasks(&client, idle), + idle, + "dropping the SDK closed every pooled channel" + ); + + // The next set builds a new SDK; the client's own state carried over. + client.set_endpoints(&[uri(3)]).expect("endpoints"); + let sdk = client.sdk().expect("rebuilt"); + assert_eq!(sdk.address_list().len(), 1); + assert_eq!(sdk.version().protocol_version, LATEST_VERSION); + assert_eq!(client.last_seen_height(), 42); + assert!(client.verified_platform_version().is_ok()); + client.shutdown(); + } + + #[test] + fn an_empty_endpoint_set_stops_an_in_flight_request_dialling_the_old_nodes() { + // Two endpoints that count the connections they get. The request's + // first attempt is held mid-connect while the set is cleared; its + // retry must not dial the other endpoint. + let (accepted, dials) = std::sync::mpsc::channel(); + let uris = [(); 2].map(|()| { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("address").port(); + let accepted = accepted.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + if accepted.send(stream).is_err() { + break; + } + } + }); + format!("https://127.0.0.1:{port}") + }); + let client = testnet_client(); + client.set_endpoints(&uris).expect("endpoints"); + let sdk = client.sdk().expect("built"); + client.provider().set_local_core_chain_locked_height(1); + + std::thread::scope(|scope| { + let request = scope.spawn(|| crate::ops::get_identity(&client, [1u8; 32]).status); + let first = dials + .recv_timeout(Duration::from_secs(10)) + .expect("the request dials an endpoint"); + + client.set_endpoints(&[]).expect("empty set"); + assert!(sdk.address_list().is_empty(), "the shared list is emptied"); + // The held connection fails, ending the first attempt. + drop(first); + + let status = request.join().expect("request thread"); + assert_eq!( + status.kind, + ffi::StatusKind::Unavailable, + "{}", + status.message + ); + }); + assert!( + dials.recv_timeout(Duration::from_millis(500)).is_err(), + "no further dial to a cleared endpoint" + ); + client.shutdown(); + } + + #[test] + fn a_shrinking_endpoint_set_drops_the_removed_channels_and_keeps_bans() { + let client = testnet_client(); + let uri = |port: u16| format!("https://127.0.0.1:{port}"); + let idle = alive_tasks(&client); + client.set_endpoints(&[uri(1), uri(2)]).expect("endpoints"); + let before = client.sdk().expect("built"); + dial_every_endpoint(&client); + let dialled = settled_tasks(&client, idle); + assert!(dialled > idle, "the dialled endpoints keep pooled channels"); + + // Adding only extends the list of the same SDK. + client + .set_endpoints(&[uri(1), uri(2), uri(3)]) + .expect("grow"); + assert_eq!(before.address_list().len(), 3); + drop(before); + assert_eq!(alive_tasks(&client), dialled, "growing keeps the pool"); + + // Removing one closes the pool; the retained entries keep their ban. + client.set_endpoints(&[uri(2), uri(3)]).expect("shrink"); + assert_eq!( + settled_tasks(&client, idle), + idle, + "the removed endpoint's channel is gone" + ); + let after = client.sdk().expect("rebuilt"); + assert_eq!(after.address_list().len(), 2); + let retained: Address = uri(2).parse().expect("address"); + assert!( + after.address_list().is_banned(&retained), + "ban state survives the rebuild" + ); + client.shutdown(); + } + + #[test] + fn endpoints_build_lazily_and_follow_the_pushed_set() { + let client = testnet_client(); + let uri = |host: &str| format!("https://{host}:1443"); + // Nothing to talk to yet: no SDK, reads are unavailable. + client.set_endpoints(&[]).expect("empty set before build"); + assert!(client.sdk().is_err()); + assert!(client.set_endpoints(&["not a uri".to_string()]).is_err()); + assert!(client + .set_endpoints(&["http://1.1.1.1:1443".to_string()]) + .is_err()); + assert!(client.set_endpoints(&["1.1.1.1:1443".to_string()]).is_err()); + assert!(client.sdk().is_err(), "a bad set builds nothing"); + + client + .set_endpoints(&[uri("1.1.1.1"), uri("2.2.2.2")]) + .expect("first non-empty set builds the SDK"); + let sdk = client.sdk().expect("built"); + assert_eq!(sdk.address_list().len(), 2); + + client + .set_endpoints(&[uri("2.2.2.2"), uri("3.3.3.3")]) + .expect("diff"); + let sdk = client.sdk().expect("rebuilt over the updated list"); + assert_eq!(sdk.address_list().len(), 2); + assert!(sdk + .address_list() + .get_live_addresses() + .iter() + .any(|address| address.to_string().contains("3.3.3.3"))); + assert!(!sdk + .address_list() + .get_live_addresses() + .iter() + .any(|address| address.to_string().contains("1.1.1.1"))); + + client + .set_endpoints(&[]) + .expect("empty set removes everything"); + assert!(client.sdk().is_err()); + assert_eq!( + client.check_ready().unwrap_err().kind, + ffi::StatusKind::Unavailable + ); + client.shutdown(); + client.shutdown(); + } + + /// The SDK decodes nothing above the response bound; the mock + /// transport never decodes, so the setting is checked where it is + /// made. + #[test] + fn responses_are_bounded_before_decoding() { + let client = testnet_client(); + client + .set_endpoints(&["https://1.1.1.1:1443".to_string()]) + .expect("endpoint"); + let sdk = client.sdk().expect("built"); + let settings = sdk.query_settings(); + assert_eq!( + settings.request_settings.max_decoding_message_size, + Some(MAX_RESPONSE_BYTES) + ); + assert_eq!(settings.request_settings.timeout, Some(REQUEST_TIMEOUT)); + client.shutdown(); + } + + #[test] + fn watermark_tolerates_three_blocks() { + let client = testnet_client(); + assert!(client.observe_height(100)); + assert!(client.observe_height(98)); + assert!(client.observe_height(97)); + assert!(!client.observe_height(96)); + assert_eq!(client.last_seen_height(), 100); + assert!(client.observe_height(104)); + assert_eq!(client.last_seen_height(), 104); + } + + #[test] + fn platform_version_starts_at_the_network_floor() { + let client = testnet_client(); + assert_eq!( + client.platform_version().protocol_version, + min_protocol_version(Network::Testnet) + ); + } + + #[test] + fn transitions_wait_for_a_verified_version_unless_the_floor_is_the_latest() { + let client = testnet_client(); + if min_protocol_version(Network::Testnet) < LATEST_VERSION { + let error = client.verified_platform_version().unwrap_err(); + assert!(error.contains("no verified read yet"), "{error}"); + client.observe_protocol_version(LATEST_VERSION); + assert_eq!( + client + .verified_platform_version() + .expect("verified") + .protocol_version, + LATEST_VERSION + ); + // Upward only. + client.observe_protocol_version(min_protocol_version(Network::Testnet)); + assert_eq!( + client + .verified_platform_version() + .expect("verified") + .protocol_version, + LATEST_VERSION + ); + } + // A network seen to run a version this build does not know closes + // the builders until the embedder is updated. + client.observe_protocol_version(LATEST_VERSION + 1); + let error = client.verified_platform_version().unwrap_err(); + assert!(error.contains("no transition can be built"), "{error}"); + let devnet = Client::new(&ffi::Config { + network: 2, + tenderdash_chain_id: "devnet".to_string(), + ..config(no_proxy()) + }) + .expect("client"); + assert_eq!( + devnet + .verified_platform_version() + .expect("the devnet floor is the latest version") + .protocol_version, + LATEST_VERSION + ); + } +} diff --git a/packages/rs-platform-cxx/src/helpers.rs b/packages/rs-platform-cxx/src/helpers.rs new file mode 100644 index 00000000000..c87157848d5 --- /dev/null +++ b/packages/rs-platform-cxx/src/helpers.rs @@ -0,0 +1,267 @@ +// 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. + +//! Pure helpers the embedder calls on its GUI thread: the DPNS label rules, +//! protocol constants, and the DIP-15 pieces that need nothing beyond the +//! 32-byte outputs Core's wallet is willing to hand over (an ECDH secret, an +//! accountReference MAC). No key material is derived here. + +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::dpp::identity::{KeyType, Purpose}; +use dash_sdk::dpp::system_data_contracts::SystemDataContract; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::dashpay::{ + recipient_key_purpose_is_acceptable_on_receive, recipient_key_purpose_is_valid, + sender_key_purpose_is_acceptable_on_receive, +}; +pub use dash_sdk::platform::dpns_usernames::{ + convert_to_homograph_safe_chars as normalize_label, is_contested_username, is_valid_username, +}; +use platform_encryption::{compact_xpub_bytes, decrypt_extended_public_key, parse_compact_xpub}; + +use crate::ffi; + +/// Credits per duff, the unit the GUI converts identity balances with. +pub const fn credits_per_duff() -> u64 { + CREDITS_PER_DUFF +} + +/// Id of a compiled-in system contract. +pub fn system_contract_id(which: ffi::SystemContract) -> Result<[u8; 32], String> { + let contract = match which { + ffi::SystemContract::Dpns => SystemDataContract::DPNS, + ffi::SystemContract::Dashpay => SystemDataContract::Dashpay, + other => return Err(format!("unknown system contract {}", other.repr)), + }; + Ok(contract.id().to_buffer()) +} + +/// Credits a contested DPNS domain registration must prefund for the vote +/// resolution, under `version`'s fee schedule. +pub fn contested_vote_fund_credits(version: &PlatformVersion) -> u64 { + version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount +} + +/// The 69-byte DIP-15 compact xpub of a contact request's +/// `encryptedPublicKey`, serialized for the C++ side. +pub fn compact_xpub_to_bytes(xpub: &ffi::CompactXpub) -> Vec { + compact_xpub_bytes(xpub.parent_fingerprint, xpub.chain_code, xpub.public_key).to_vec() +} + +/// Decrypts a contact request's `encryptedPublicKey` (16-byte IV followed by +/// the AES-256-CBC ciphertext) with the ECDH secret Core derived. +pub fn dip15_decrypt_xpub( + shared_secret: &[u8; 32], + ciphertext: &[u8], +) -> Result { + let plaintext = decrypt_extended_public_key(shared_secret, ciphertext) + .map_err(|e| format!("unable to decrypt the contact's public key: {e}"))?; + let xpub = parse_compact_xpub(&plaintext).map_err(|e| e.to_string())?; + Ok(ffi::CompactXpub { + parent_fingerprint: xpub.parent_fingerprint, + chain_code: xpub.chain_code, + public_key: xpub.public_key, + }) +} + +/// `ASK28`: the 28 low bits of the big-endian tail of the accountReference +/// MAC, the reading iOS dash-shared-core uses (`platform-encryption`'s +/// `extract_ask28`, inlined until it is exported). +fn ask28(mac: &[u8; 32]) -> u32 { + u32::from_be_bytes([mac[28], mac[29], mac[30], mac[31]]) >> 4 +} + +/// Masks `account_index` into a DIP-15 `accountReference` carrying the +/// rotation `version` in its top four bits (`platform-encryption`'s +/// `calculate_account_reference` over a MAC Core computed with the +/// ENCRYPTION key it never exports). +pub fn dip15_account_reference_from_mac(mac: &[u8; 32], account_index: u32, version: u32) -> u32 { + (version << 28) | (ask28(mac) ^ (account_index & 0x0FFF_FFFF)) +} + +/// Inverse of [`dip15_account_reference_from_mac`] for the same MAC. +pub fn dip15_unmask_account_reference_from_mac( + mac: &[u8; 32], + account_reference: u32, +) -> ffi::AccountRef { + ffi::AccountRef { + version: account_reference >> 28, + account_index: (account_reference & 0x0FFF_FFFF) ^ ask28(mac), + } +} + +/// The recipient key a contact request to `identity` should reference: an +/// enabled ECDSA key whose purpose `dash-sdk` accepts when minting, a +/// DECRYPTION key before an ENCRYPTION one, lowest id first. +pub fn dip15_select_recipient_key(identity: &ffi::Identity) -> Result { + let mut eligible: Vec<(u32, Purpose)> = identity + .keys + .iter() + .filter_map(|key| { + let purpose = Purpose::try_from(key.purpose).ok()?; + let key_type = KeyType::try_from(key.key_type).ok()?; + (recipient_key_purpose_is_valid(purpose) + && key_type == KeyType::ECDSA_SECP256K1 + && key.disabled_at == 0) + .then_some((key.id, purpose)) + }) + .collect(); + eligible.sort_by_key(|(id, purpose)| (*purpose != Purpose::DECRYPTION, *id)); + eligible.first().map(|(id, _)| *id).ok_or_else(|| { + "the recipient has no enabled ECDSA DECRYPTION or ENCRYPTION key".to_string() + }) +} + +/// Whether an inbound contact request's key references are acceptable for +/// the ECDH that unwraps its `encryptedPublicKey`: `dash-sdk`'s receive-side +/// purpose policy, plus the rule that Core never runs ECDH with its MASTER +/// key (id 0). +pub fn dip15_receive_keys_acceptable( + sender_purpose: u8, + recipient_purpose: u8, + recipient_key_id: u32, +) -> bool { + let (Ok(sender), Ok(recipient)) = ( + Purpose::try_from(sender_purpose), + Purpose::try_from(recipient_purpose), + ) else { + return false; + }; + recipient_key_id >= 1 + && sender_key_purpose_is_acceptable_on_receive(sender) + && recipient_key_purpose_is_acceptable_on_receive(recipient) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(id: u32, purpose: Purpose, key_type: KeyType, disabled_at: u64) -> ffi::IdentityKey { + ffi::IdentityKey { + id, + purpose: purpose as u8, + key_type: key_type as u8, + disabled_at, + ..Default::default() + } + } + + #[test] + fn recipient_key_prefers_decryption_then_lowest_id() { + let identity = ffi::Identity { + keys: vec![ + key(0, Purpose::AUTHENTICATION, KeyType::ECDSA_SECP256K1, 0), + key(2, Purpose::ENCRYPTION, KeyType::ECDSA_SECP256K1, 0), + key(3, Purpose::DECRYPTION, KeyType::ECDSA_SECP256K1, 0), + key(4, Purpose::DECRYPTION, KeyType::ECDSA_SECP256K1, 0), + ], + ..Default::default() + }; + assert_eq!(dip15_select_recipient_key(&identity), Ok(3)); + } + + #[test] + fn recipient_key_skips_disabled_and_non_ecdsa_keys() { + let identity = ffi::Identity { + keys: vec![ + key(2, Purpose::DECRYPTION, KeyType::ECDSA_SECP256K1, 1), + key(3, Purpose::DECRYPTION, KeyType::BLS12_381, 0), + key(5, Purpose::ENCRYPTION, KeyType::ECDSA_SECP256K1, 0), + ], + ..Default::default() + }; + assert_eq!(dip15_select_recipient_key(&identity), Ok(5)); + let none = ffi::Identity { + keys: vec![key(1, Purpose::AUTHENTICATION, KeyType::ECDSA_SECP256K1, 0)], + ..Default::default() + }; + assert!(dip15_select_recipient_key(&none).is_err()); + } + + #[test] + fn receive_policy_never_accepts_the_master_key() { + let encryption = Purpose::ENCRYPTION as u8; + let authentication = Purpose::AUTHENTICATION as u8; + assert!(dip15_receive_keys_acceptable(encryption, encryption, 2)); + // The legacy Android cohort references AUTHENTICATION keys on both + // sides; still payable, as long as it is not key 0. + assert!(dip15_receive_keys_acceptable( + authentication, + authentication, + 1 + )); + assert!(!dip15_receive_keys_acceptable( + authentication, + authentication, + 0 + )); + assert!(!dip15_receive_keys_acceptable( + Purpose::VOTING as u8, + encryption, + 2 + )); + assert!(!dip15_receive_keys_acceptable( + encryption, + Purpose::SYSTEM as u8, + 2 + )); + assert!(!dip15_receive_keys_acceptable(200, encryption, 2)); + } + + #[test] + fn account_reference_round_trips_and_pins_the_ios_extraction() { + let mac: [u8; 32] = std::array::from_fn(|i| i as u8); + // be(mac[28..32]) >> 4 for 28,29,30,31 = 0x1c1d1e1f >> 4. + assert_eq!(ask28(&mac), 0x01c1_d1e1); + for version in [0u32, 1, 7, 15] { + for account in [0u32, 1, 5, 0x0FFF_FFFF] { + let reference = dip15_account_reference_from_mac(&mac, account, version); + let unmasked = dip15_unmask_account_reference_from_mac(&mac, reference); + assert_eq!( + (unmasked.version, unmasked.account_index), + (version, account) + ); + } + } + assert_eq!(dip15_account_reference_from_mac(&mac, 0, 0), 0x01c1_d1e1); + } + + #[test] + fn xpub_round_trip_through_dip15_encryption() { + let secret = [0x42u8; 32]; + let xpub = ffi::CompactXpub { + parent_fingerprint: [1, 2, 3, 4], + chain_code: [0x55; 32], + public_key: std::array::from_fn(|i| if i == 0 { 2 } else { i as u8 }), + }; + let ciphertext = platform_encryption::encrypt_extended_public_key( + &secret, + &[7u8; 16], + &compact_xpub_to_bytes(&xpub), + ); + assert_eq!(ciphertext.len(), 96); + let decrypted = dip15_decrypt_xpub(&secret, &ciphertext).expect("decrypts"); + assert_eq!(decrypted.parent_fingerprint, xpub.parent_fingerprint); + assert_eq!(decrypted.chain_code, xpub.chain_code); + assert_eq!(decrypted.public_key, xpub.public_key); + assert!(dip15_decrypt_xpub(&[0u8; 32], &ciphertext).is_err()); + assert!(dip15_decrypt_xpub(&secret, &ciphertext[..40]).is_err()); + } + + #[test] + fn system_contract_ids_match_dpp() { + assert_eq!( + system_contract_id(ffi::SystemContract::Dpns), + Ok(SystemDataContract::DPNS.id().to_buffer()) + ); + assert_eq!( + system_contract_id(ffi::SystemContract::Dashpay), + Ok(SystemDataContract::Dashpay.id().to_buffer()) + ); + assert!(system_contract_id(ffi::SystemContract { repr: 9 }).is_err()); + } +} diff --git a/packages/rs-platform-cxx/src/lib.rs b/packages/rs-platform-cxx/src/lib.rs new file mode 100644 index 00000000000..13d8c1188b6 --- /dev/null +++ b/packages/rs-platform-cxx/src/lib.rs @@ -0,0 +1,963 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! Dash Platform for C++ embedders, as a thin `cxx` shell over `dash-sdk`. +//! +//! `dash-sdk` owns transport, retries, proof verification, the signed-time +//! window and the protocol-version ratchet. This crate adds what only the +//! embedder can supply or decide: a push-model trust context (`provider`: +//! endpoints, Platform quorum keys, the local ChainLock height), one tokio +//! runtime (`runtime`), the SDK instance and its endpoint set (`client`), the +//! proved reads with the shell's own freshness checks (`ops`), the two signer +//! adapters over the embedder's wallet (`signer`), state-transition assembly +//! over dpp (`builders`) and a few pure helpers (`helpers`). +//! +//! Every bridge entry point runs under [`guarded`]: cxx turns an `Err` into a +//! C++ `rust::Error`, but a panic reaching its shim aborts the embedding +//! process, and every response byte comes from an untrusted node. The crate +//! therefore refuses to build with `panic = "abort"`. + +#[cfg(panic = "abort")] +compile_error!( + "dash-platform-cxx requires panic = \"unwind\"; its FFI guards rely on catch_unwind" +); + +pub mod builders; +pub mod client; +pub mod helpers; +pub mod ops; +pub mod provider; +pub mod runtime; +pub mod signer; +mod sync; + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use client::Client; +use zeroize::Zeroizing; + +#[cxx::bridge(namespace = "platform_ffi")] +pub mod ffi { + /// Outcome class of a bridge call. The value of a `Verified*` result is + /// only meaningful under `Ok` and `UnsupportedProtocolVersion`; the + /// fine-grained reason is in `Status::message`, for logs. + /// + /// Read results carry no `UnsupportedProtocolVersion` for a proven + /// absence: it is `ProvenAbsent` whatever the version, the version + /// itself is in `meta.protocol_version`, and the builders refuse until + /// the embedder is updated. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum StatusKind { + Ok = 0, + /// The proof shows the queried object (or every match) does not exist. + ProvenAbsent = 1, + /// Broadcast: a node already holds this state transition. + AlreadyExists = 2, + /// Broadcast: a node rejected the state transition with a consensus + /// error; `consensus_code` carries its code. + Consensus = 3, + /// No endpoints, no local ChainLock anchor, the client is shut down, + /// a transport failure or timeout, every address is banned, or a + /// read the network's protocol version cannot answer correctly. + Unavailable = 4, + /// A proof, signature, quorum, freshness or shell-watermark check + /// refused the response, or a node definitively refused the request + /// (a broadcast without a consensus code, a read it would not serve, + /// a response above the size bound). + Rejected = 5, + /// A verified response signed for another Tenderdash chain. + ChainIdMismatch = 6, + /// A verified response from a protocol version this build does not + /// know; the value is returned, writes must stop until an update. + UnsupportedProtocolVersion = 7, + /// A bug: bad input from the embedder, a panic, or an SDK error that + /// no other kind describes. + Internal = 8, + } + + #[derive(Debug, Clone, Default)] + struct Status { + kind: StatusKind, + consensus_code: u32, + message: String, + } + + /// The SOCKS5 proxy every Platform connection goes through. `kind`: + /// 0 none (connect directly; `address` must be empty), 1 TCP (`address` + /// is a numeric `ip:port`, an IPv6 address in brackets), 2 Unix socket + /// (`address` is its path). `isolate`: fresh random SOCKS5 credentials + /// for every connection, so Tor gives each its own circuit (Dash Core's + /// `-proxyrandomize`). There is no fallback to a direct connection. + #[derive(Debug, Clone)] + struct Proxy { + kind: u8, + address: String, + isolate: bool, + } + + /// Network the client serves. `network`: 0 mainnet, 1 testnet, + /// 2 devnet, 3 regtest. `platform_llmq_type`: the LLMQ type Platform + /// quorums use on this network; a proof signed by any other type is + /// refused. `tenderdash_chain_id`: a verified response carrying another + /// id is a signed response from another chain. `proxy` is fixed for the + /// client's lifetime. + #[derive(Debug, Clone)] + struct Config { + network: u8, + tenderdash_chain_id: String, + platform_llmq_type: u8, + proxy: Proxy, + } + + /// A Platform quorum's BLS public key. `hash` is the quorum hash in + /// the embedder's internal `uint256` byte order; the shell normalizes. + #[derive(Debug, Clone)] + struct QuorumKey { + hash: [u8; 32], + pubkey: [u8; 48], + } + + /// The authenticated `ResponseMetadata` of a verified response; the + /// quorum signature covers every field through the signed `StateId`. + #[derive(Debug, Clone, Default)] + struct Meta { + height: u64, + core_chain_locked_height: u32, + time_ms: u64, + protocol_version: u32, + chain_id: String, + } + + /// Where an identity key may be used. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum BoundsKind { + NoBounds = 0, + SingleContract = 1, + SingleContractDocumentType = 2, + /// The members of the contract group `contract_id`. + ContractGroup = 3, + } + + #[derive(Debug, Clone, Default)] + struct ContractBounds { + kind: BoundsKind, + contract_id: [u8; 32], + document_type: String, + } + + /// One identity public key; `disabled_at == 0` means enabled. + #[derive(Debug, Clone, Default)] + struct IdentityKey { + id: u32, + purpose: u8, + security_level: u8, + key_type: u8, + read_only: bool, + data: Vec, + disabled_at: u64, + bounds: ContractBounds, + } + + #[derive(Debug, Clone, Default)] + struct Identity { + id: [u8; 32], + balance: u64, + revision: u64, + keys: Vec, + } + + /// A DPNS `domain` document. + #[derive(Debug, Clone, Default)] + struct DpnsName { + label: String, + normalized_label: String, + parent: String, + identity: [u8; 32], + document_id: [u8; 32], + owner: [u8; 32], + } + + /// A DashPay `profile` document. Empty strings and vectors mean the + /// field is absent. The avatar and payment-address fields are carried + /// for `build_profile`, which keeps them on a replace; the embedder + /// renders none of them. + #[derive(Debug, Clone, Default)] + struct Profile { + document_id: [u8; 32], + owner: [u8; 32], + revision: u64, + display_name: String, + public_message: String, + avatar_url: String, + avatar_hash: Vec, + avatar_fingerprint: Vec, + core_payment_address: Vec, + platform_payment_address: Vec, + shielded_address: Vec, + created_at: u64, + updated_at: u64, + } + + /// A DashPay `contactRequest` document. + #[derive(Debug, Clone, Default)] + struct ContactRequest { + document_id: [u8; 32], + owner: [u8; 32], + to_user_id: [u8; 32], + encrypted_public_key: Vec, + sender_key_index: u32, + recipient_key_index: u32, + account_reference: u32, + encrypted_account_label: Vec, + auto_accept_proof: Vec, + created_at: u64, + core_height_created_at: u32, + } + + #[derive(Debug, Clone, Default)] + struct Contender { + identity: [u8; 32], + votes: u32, + has_votes: bool, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum WinnerKind { + /// The contest is still open. + NoWinner = 0, + WonByIdentity = 1, + Locked = 2, + } + + /// The vote state of a contested DPNS name. `ends_at` is the time of + /// the block that finalized the contest, 0 while it is open. + #[derive(Debug, Clone, Default)] + struct ContestedState { + contenders: Vec, + abstain: u32, + lock: u32, + winner_kind: WinnerKind, + winner: [u8; 32], + ends_at: u64, + } + + /// Cursor of a paged read: pass `next_start_after` to the next call + /// while `has_more`. One bridge call is one page. + #[derive(Debug, Clone, Default)] + struct Page { + next_start_after: [u8; 32], + has_more: bool, + } + + #[derive(Debug, Clone, Default)] + struct VerifiedIdentity { + status: Status, + meta: Meta, + value: Identity, + } + + #[derive(Debug, Clone, Default)] + struct VerifiedU64 { + status: Status, + meta: Meta, + value: u64, + } + + #[derive(Debug, Clone, Default)] + struct VerifiedDpnsName { + status: Status, + meta: Meta, + value: DpnsName, + } + + #[derive(Debug, Clone, Default)] + struct VerifiedDpnsNames { + status: Status, + meta: Meta, + items: Vec, + page: Page, + } + + #[derive(Debug, Clone, Default)] + struct VerifiedProfile { + status: Status, + meta: Meta, + value: Profile, + } + + #[derive(Debug, Clone, Default)] + struct VerifiedContactRequests { + status: Status, + meta: Meta, + items: Vec, + page: Page, + } + + #[derive(Debug, Clone, Default)] + struct VerifiedContested { + status: Status, + meta: Meta, + value: ContestedState, + } + + /// The node's answer to a broadcast: advisory, never proof-backed. + /// Success is `Ok` or `AlreadyExists`; the embedder confirms every + /// write with a proved re-query. + #[derive(Debug, Clone, Default)] + struct BroadcastResult { + status: Status, + } + + /// A signed state transition. `hash` is its transaction id (single + /// SHA256 of `bytes`); `object_id` the identity id or document id it + /// creates or replaces. + #[derive(Debug, Clone, Default)] + struct Built { + bytes: Vec, + hash: [u8; 32], + object_id: [u8; 32], + } + + /// A key to register with a new identity; always ECDSA secp256k1. + #[derive(Debug, Clone)] + struct NewIdentityKey { + id: u32, + purpose: u8, + security_level: u8, + pubkey: [u8; 33], + bounds: ContractBounds, + } + + /// The funding of an identity registration: an InstantSend-locked + /// asset-lock transaction (`is_instant`, consensus-encoded transaction + /// and islock, `output_index` of the credit output) or a ChainLocked + /// outpoint (`core_chain_locked_height`, `out_point` as txid ‖ vout). + #[derive(Debug, Clone)] + struct AssetLockProofInput { + is_instant: bool, + transaction: Vec, + instant_lock: Vec, + output_index: u32, + core_chain_locked_height: u32, + out_point: [u8; 36], + } + + /// The fields of a profile the embedder edits; an empty string omits + /// the field. + #[derive(Debug, Clone, Default)] + struct ProfileInput { + display_name: String, + public_message: String, + } + + /// DIP-15 compact extended public key: parent fingerprint ‖ chain code + /// ‖ compressed public key, the plaintext of `encryptedPublicKey`. + #[derive(Debug, Clone)] + struct CompactXpub { + parent_fingerprint: [u8; 4], + chain_code: [u8; 32], + public_key: [u8; 33], + } + + /// A contact request to mint. `shared_secret` is the ECDH secret the + /// embedder derived between its `sender_key_index` key and the + /// recipient's `recipient_pubkey` (the key at `recipient_key_index`); + /// `account_reference` is already masked; `account_label` empty = none. + #[derive(Debug, Clone)] + struct ContactRequestInput { + to_user_id: [u8; 32], + sender_key_index: u32, + recipient_key_index: u32, + recipient_pubkey: [u8; 33], + account_reference: u32, + compact_xpub: CompactXpub, + shared_secret: [u8; 32], + account_label: String, + } + + /// An unmasked DIP-15 `accountReference`. + #[derive(Debug, Clone, Default)] + struct AccountRef { + version: u32, + account_index: u32, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum SystemContract { + Dpns = 0, + Dashpay = 1, + } + + unsafe extern "C++" { + include!("dash/platform/signer.h"); + + /// The embedder's wallet. `SignForKey` receives the identity key id + /// and the full signable preimage and answers with a 65-byte compact + /// recoverable ECDSA signature over its double SHA256. + /// `SignAssetLockSighash` signs the 32-byte digest with the asset + /// lock's outpoint key. Both are called synchronously on the thread + /// that called the builder; `false` refuses. + type WalletSigner; + fn SignForKey( + self: &WalletSigner, + key_id: u32, + signable: &[u8], + sig_out: &mut Vec, + ) -> bool; + fn SignAssetLockSighash( + self: &WalletSigner, + sighash: &[u8; 32], + sig_out: &mut Vec, + ) -> bool; + } + + extern "Rust" { + /// One Platform SDK instance with its runtime, endpoint set, trust + /// context and freshness state. Thread-safe; reads block the + /// calling thread until the request completes or `shutdown`. + type PlatformClient; + + fn new_platform_client(cfg: &Config) -> Result>; + + // --- Trust inputs, pushed by the embedder --------------------------- + /// Replaces the evonode endpoint set (`https://host:port`, the host + /// an IP address, or an onion name when a proxy is configured). An + /// empty set drops the SDK and closes every Platform connection; a + /// set that removes endpoints closes the connections to them (the + /// retained entries keep their ban state). Trust inputs, freshness + /// state and the verified protocol version are kept throughout. + fn set_endpoints(self: &PlatformClient, https_uris: &[String]) -> Result<()>; + /// Replaces the Platform quorum keys (the full active set). + fn set_quorum_keys(self: &PlatformClient, keys: &[QuorumKey]); + /// Pushes the embedder's best ChainLock height, the anchor of the + /// proof-staleness floor. Monotonic; until the first push proved + /// reads are not dispatched and return `Unavailable`. + fn set_chainlock_height(self: &PlatformClient, height: u32); + /// Aborts the in-flight request and releases the runtime. Reads + /// afterwards return `Unavailable`. Idempotent. + fn shutdown(self: &PlatformClient); + + // --- Proved reads, one SDK request each ----------------------------- + fn get_identity(self: &PlatformClient, id: &[u8; 32]) -> VerifiedIdentity; + fn get_identity_by_pubkey_hash( + self: &PlatformClient, + pubkey_hash: &[u8; 20], + ) -> VerifiedIdentity; + /// `ProvenAbsent` = the identity has not used the contract yet; + /// the next nonce is then 1, as after a value of 0. + fn get_identity_contract_nonce( + self: &PlatformClient, + id: &[u8; 32], + contract_id: &[u8; 32], + ) -> VerifiedU64; + fn resolve_name(self: &PlatformClient, normalized_label: &str) -> VerifiedDpnsName; + /// One page of up to `limit` (clamped to 1..=100) names starting + /// with `prefix` (1 to 63 characters once normalized), ascending; + /// `start_after` all zero starts over. + fn search_names( + self: &PlatformClient, + prefix: &str, + limit: u32, + start_after: &[u8; 32], + ) -> VerifiedDpnsNames; + /// One page of up to 100 names; `start_after` all zero starts over. + /// Below protocol version 14 a continuation is `Unavailable`: Drive + /// answers it with an empty page, whatever names remain. + fn names_of_identity( + self: &PlatformClient, + identity: &[u8; 32], + start_after: &[u8; 32], + ) -> VerifiedDpnsNames; + fn get_profile(self: &PlatformClient, owner: &[u8; 32]) -> VerifiedProfile; + /// One page of up to 100 requests sent to (`to_me`) or by + /// `identity`, created after `since_ms` (0 = all), oldest first. + fn get_contact_requests( + self: &PlatformClient, + identity: &[u8; 32], + to_me: bool, + since_ms: u64, + start_after: &[u8; 32], + ) -> VerifiedContactRequests; + fn get_contested_vote_state( + self: &PlatformClient, + normalized_label: &str, + ) -> VerifiedContested; + + // --- Broadcast -------------------------------------------------------- + fn broadcast(self: &PlatformClient, state_transition: &[u8]) -> BroadcastResult; + + // --- Builders: no network, signer called on the calling thread ------ + // Every builder needs the protocol version a verified read has shown + // the network to run (an error before the first successful read), + // since transition rules change across versions. + fn build_identity_create( + self: &PlatformClient, + proof: &AssetLockProofInput, + keys: &[NewIdentityKey], + signer: &WalletSigner, + ) -> Result; + fn build_dpns_preorder( + self: &PlatformClient, + owner: &[u8; 32], + nonce: u64, + label: &str, + salt: &[u8; 32], + key: &IdentityKey, + signer: &WalletSigner, + ) -> Result; + fn build_dpns_domain( + self: &PlatformClient, + owner: &[u8; 32], + nonce: u64, + label: &str, + salt: &[u8; 32], + key: &IdentityKey, + signer: &WalletSigner, + ) -> Result; + /// Creates the profile when `existing.document_id` is all zero, + /// otherwise replaces `existing` (as `get_profile` returned it) at + /// its revision + 1, keeping every field `input` does not edit. + fn build_profile( + self: &PlatformClient, + owner: &[u8; 32], + nonce: u64, + existing: &Profile, + input: &ProfileInput, + key: &IdentityKey, + signer: &WalletSigner, + ) -> Result; + fn build_contact_request( + self: &PlatformClient, + sender: &Identity, + recipient: &Identity, + nonce: u64, + input: &ContactRequestInput, + key: &IdentityKey, + signer: &WalletSigner, + ) -> Result; + + // --- Pure helpers ----------------------------------------------------- + fn normalize_label(label: &str) -> String; + fn is_valid_username(label: &str) -> bool; + fn is_contested_username(label: &str) -> bool; + /// Credits a contested name registration prefunds, at the protocol + /// version a verified read has shown the network to run; an error + /// before that (the amount changed across versions). + fn contested_vote_fund_credits(self: &PlatformClient) -> Result; + fn credits_per_duff() -> u64; + fn system_contract_id(which: SystemContract) -> Result<[u8; 32]>; + fn dip15_decrypt_xpub(shared_secret: &[u8; 32], ciphertext: &[u8]) -> Result; + fn dip15_account_reference_from_mac( + mac: &[u8; 32], + account_index: u32, + version: u32, + ) -> u32; + fn dip15_unmask_account_reference_from_mac(mac: &[u8; 32], reference: u32) -> AccountRef; + fn dip15_select_recipient_key(identity: &Identity) -> Result; + fn dip15_receive_keys_acceptable( + sender_purpose: u8, + recipient_purpose: u8, + recipient_key_id: u32, + ) -> bool; + } +} + +impl Default for ffi::StatusKind { + /// `Internal`, so a result that was never filled in cannot read as + /// success. + fn default() -> Self { + ffi::StatusKind::Internal + } +} + +impl Default for ffi::BoundsKind { + fn default() -> Self { + ffi::BoundsKind::NoBounds + } +} + +impl Default for ffi::WinnerKind { + fn default() -> Self { + ffi::WinnerKind::NoWinner + } +} + +impl ffi::Status { + pub fn ok() -> Self { + ffi::Status { + kind: ffi::StatusKind::Ok, + consensus_code: 0, + message: String::new(), + } + } + + pub fn new(kind: ffi::StatusKind, message: impl Into) -> Self { + ffi::Status { + kind, + consensus_code: 0, + message: message.into(), + } + } + + pub fn internal(message: impl Into) -> Self { + Self::new(ffi::StatusKind::Internal, message) + } + + pub fn unavailable(message: impl Into) -> Self { + Self::new(ffi::StatusKind::Unavailable, message) + } +} + +/// The bridge's opaque client type. Dropping it shuts the client down. +pub struct PlatformClient(Client); + +impl PlatformClient { + /// The wrapped client, for Rust callers (tests) that configure it + /// directly. + pub fn inner(&self) -> &Client { + &self.0 + } +} + +impl Drop for PlatformClient { + fn drop(&mut self) { + // Third-party drop paths run here (tonic channels, the runtime); a + // panic on the way out must not abort the embedder. + let _ = catch_unwind(AssertUnwindSafe(|| self.0.shutdown())); + } +} + +/// Runs a bridge body, turning a panic into the value `on_panic` builds +/// from the panic message (an `Err` the C++ side receives as `rust::Error`, +/// or an `Internal` status) instead of the process abort cxx would perform. +fn guarded(what: &str, body: impl FnOnce() -> T, on_panic: impl FnOnce(String) -> T) -> T { + catch_unwind(AssertUnwindSafe(body)).unwrap_or_else(|payload| { + let message = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or(""); + on_panic(format!("{what} panicked: {message}")) + }) +} + +fn fallible(what: &str, body: impl FnOnce() -> Result) -> Result { + guarded(what, body, Err) +} + +/// [`guarded`] for the infallible entry points: a panic yields the type's +/// default (`()`, `false`, `0`, an empty string or struct), which the +/// embedder reads as a refusal. +fn guarded_default(what: &str, body: impl FnOnce() -> T) -> T { + guarded(what, body, |_| T::default()) +} + +fn new_platform_client(cfg: &ffi::Config) -> Result, String> { + fallible("new_platform_client", || { + Client::new(cfg).map(|client| Box::new(PlatformClient(client))) + }) +} + +impl PlatformClient { + fn set_endpoints(&self, https_uris: &[String]) -> Result<(), String> { + fallible("set_endpoints", || self.0.set_endpoints(https_uris)) + } + + fn set_quorum_keys(&self, keys: &[ffi::QuorumKey]) { + guarded_default("set_quorum_keys", || { + self.0.provider().set_quorum_keys(keys) + }) + } + + fn set_chainlock_height(&self, height: u32) { + guarded_default("set_chainlock_height", || { + self.0.provider().set_local_core_chain_locked_height(height) + }) + } + + fn shutdown(&self) { + guarded_default("shutdown", || self.0.shutdown()) + } + + fn get_identity(&self, id: &[u8; 32]) -> ffi::VerifiedIdentity { + guarded( + "get_identity", + || ops::get_identity(&self.0, *id), + ops::failed, + ) + } + + fn get_identity_by_pubkey_hash(&self, pubkey_hash: &[u8; 20]) -> ffi::VerifiedIdentity { + guarded( + "get_identity_by_pubkey_hash", + || ops::get_identity_by_pubkey_hash(&self.0, *pubkey_hash), + ops::failed, + ) + } + + fn get_identity_contract_nonce( + &self, + id: &[u8; 32], + contract_id: &[u8; 32], + ) -> ffi::VerifiedU64 { + guarded( + "get_identity_contract_nonce", + || ops::get_identity_contract_nonce(&self.0, *id, *contract_id), + ops::failed, + ) + } + + fn resolve_name(&self, normalized_label: &str) -> ffi::VerifiedDpnsName { + guarded( + "resolve_name", + || ops::resolve_name(&self.0, normalized_label), + ops::failed, + ) + } + + fn search_names( + &self, + prefix: &str, + limit: u32, + start_after: &[u8; 32], + ) -> ffi::VerifiedDpnsNames { + guarded( + "search_names", + || ops::search_names(&self.0, prefix, limit, cursor(start_after)), + ops::failed, + ) + } + + fn names_of_identity( + &self, + identity: &[u8; 32], + start_after: &[u8; 32], + ) -> ffi::VerifiedDpnsNames { + guarded( + "names_of_identity", + || ops::names_of_identity(&self.0, *identity, cursor(start_after)), + ops::failed, + ) + } + + fn get_profile(&self, owner: &[u8; 32]) -> ffi::VerifiedProfile { + guarded( + "get_profile", + || ops::get_profile(&self.0, *owner), + ops::failed, + ) + } + + fn get_contact_requests( + &self, + identity: &[u8; 32], + to_me: bool, + since_ms: u64, + start_after: &[u8; 32], + ) -> ffi::VerifiedContactRequests { + guarded( + "get_contact_requests", + || ops::get_contact_requests(&self.0, *identity, to_me, since_ms, cursor(start_after)), + ops::failed, + ) + } + + fn get_contested_vote_state(&self, normalized_label: &str) -> ffi::VerifiedContested { + guarded( + "get_contested_vote_state", + || ops::get_contested_vote_state(&self.0, normalized_label), + ops::failed, + ) + } + + fn broadcast(&self, state_transition: &[u8]) -> ffi::BroadcastResult { + guarded( + "broadcast", + || ops::broadcast(&self.0, state_transition), + |message| ffi::BroadcastResult { + status: ffi::Status::internal(message), + }, + ) + } + + fn build_identity_create( + &self, + proof: &ffi::AssetLockProofInput, + keys: &[ffi::NewIdentityKey], + signer: &ffi::WalletSigner, + ) -> Result { + fallible("build_identity_create", || { + builders::build_identity_create( + self.0.verified_platform_version()?, + proof, + keys, + signer, + ) + }) + } + + fn build_dpns_preorder( + &self, + owner: &[u8; 32], + nonce: u64, + label: &str, + salt: &[u8; 32], + key: &ffi::IdentityKey, + signer: &ffi::WalletSigner, + ) -> Result { + fallible("build_dpns_preorder", || { + builders::build_dpns_preorder( + self.0.verified_platform_version()?, + *owner, + nonce, + label, + salt, + builders::fresh_entropy(), + key, + signer, + ) + }) + } + + fn build_dpns_domain( + &self, + owner: &[u8; 32], + nonce: u64, + label: &str, + salt: &[u8; 32], + key: &ffi::IdentityKey, + signer: &ffi::WalletSigner, + ) -> Result { + fallible("build_dpns_domain", || { + builders::build_dpns_domain( + self.0.verified_platform_version()?, + *owner, + nonce, + label, + salt, + builders::fresh_entropy(), + key, + signer, + ) + }) + } + + fn build_profile( + &self, + owner: &[u8; 32], + nonce: u64, + existing: &ffi::Profile, + input: &ffi::ProfileInput, + key: &ffi::IdentityKey, + signer: &ffi::WalletSigner, + ) -> Result { + fallible("build_profile", || { + builders::build_profile( + self.0.verified_platform_version()?, + *owner, + nonce, + existing, + input, + builders::fresh_entropy(), + key, + signer, + ) + }) + } + + fn build_contact_request( + &self, + sender: &ffi::Identity, + recipient: &ffi::Identity, + nonce: u64, + input: &ffi::ContactRequestInput, + key: &ffi::IdentityKey, + signer: &ffi::WalletSigner, + ) -> Result { + fallible("build_contact_request", || { + let version = self.0.verified_platform_version()?; + let sdk = self.0.sdk()?; + builders::build_contact_request( + &sdk, version, sender, recipient, nonce, input, key, signer, + ) + }) + } + + fn contested_vote_fund_credits(&self) -> Result { + fallible("contested_vote_fund_credits", || { + Ok(helpers::contested_vote_fund_credits( + self.0.verified_platform_version()?, + )) + }) + } +} + +/// A paging cursor: all zero means "from the start". +fn cursor(start_after: &[u8; 32]) -> Option<[u8; 32]> { + (*start_after != [0u8; 32]).then_some(*start_after) +} + +fn normalize_label(label: &str) -> String { + guarded_default("normalize_label", || helpers::normalize_label(label)) +} + +fn is_valid_username(label: &str) -> bool { + guarded_default("is_valid_username", || helpers::is_valid_username(label)) +} + +fn is_contested_username(label: &str) -> bool { + guarded_default("is_contested_username", || { + helpers::is_contested_username(label) + }) +} + +fn credits_per_duff() -> u64 { + helpers::credits_per_duff() +} + +fn system_contract_id(which: ffi::SystemContract) -> Result<[u8; 32], String> { + fallible("system_contract_id", || helpers::system_contract_id(which)) +} + +fn dip15_decrypt_xpub( + shared_secret: &[u8; 32], + ciphertext: &[u8], +) -> Result { + fallible("dip15_decrypt_xpub", || { + let secret = Zeroizing::new(*shared_secret); + helpers::dip15_decrypt_xpub(&secret, ciphertext) + }) +} + +fn dip15_account_reference_from_mac(mac: &[u8; 32], account_index: u32, version: u32) -> u32 { + guarded_default("dip15_account_reference_from_mac", || { + helpers::dip15_account_reference_from_mac(mac, account_index, version) + }) +} + +fn dip15_unmask_account_reference_from_mac(mac: &[u8; 32], reference: u32) -> ffi::AccountRef { + guarded_default("dip15_unmask_account_reference_from_mac", || { + helpers::dip15_unmask_account_reference_from_mac(mac, reference) + }) +} + +fn dip15_select_recipient_key(identity: &ffi::Identity) -> Result { + fallible("dip15_select_recipient_key", || { + helpers::dip15_select_recipient_key(identity) + }) +} + +fn dip15_receive_keys_acceptable( + sender_purpose: u8, + recipient_purpose: u8, + recipient_key_id: u32, +) -> bool { + guarded_default("dip15_receive_keys_acceptable", || { + helpers::dip15_receive_keys_acceptable(sender_purpose, recipient_purpose, recipient_key_id) + }) +} diff --git a/packages/rs-platform-cxx/src/ops.rs b/packages/rs-platform-cxx/src/ops.rs new file mode 100644 index 00000000000..20d9f6912a8 --- /dev/null +++ b/packages/rs-platform-cxx/src/ops.rs @@ -0,0 +1,950 @@ +// 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. + +//! The proved reads and the broadcast, one SDK request each. Every read +//! goes through the SDK's `Fetch` / `FetchMany`, so the SDK verifies the +//! response against the query it built, then through [`accept`]: the +//! chain-id check, the shell's Platform-height watermark and the +//! unsupported-protocol-version signal, in that order, on metadata the +//! quorum signature already covers. +//! +//! Absence is proven, not inferred: `ProvenAbsent` comes back only after +//! the SDK verified a proof of it. Every failure is classified into a +//! `Status` kind on the innermost `dash_sdk::Error`, never on its text. + +use std::future::Future; + +use dash_sdk::dapi_client::transport::TransportError; +use dash_sdk::dapi_client::{ + CanRetry, DapiClientError, DapiRequest, DapiRequestExecutor, RequestSettings, +}; +use dash_sdk::dpp::consensus::codes::ErrorWithCode; +use dash_sdk::dpp::document::{Document, DocumentV0Getters}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_nonce::IDENTITY_NONCE_VALUE_FILTER; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; +use dash_sdk::dpp::identity::{Identity, IdentityPublicKey}; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::prelude::Identifier; +use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars; +use dash_sdk::dpp::version::v14::PROTOCOL_VERSION_14; +use dash_sdk::dpp::version::{PlatformVersion, LATEST_VERSION}; +use dash_sdk::dpp::voting::contender_structs::ContenderWithSerializedDocument; +use dash_sdk::dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; +use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use dash_sdk::dpp::ProtocolError; +use dash_sdk::drive::query::vote_poll_vote_state_query::{ + ContestedDocumentVotePollDriveQuery, ContestedDocumentVotePollDriveQueryResultType, +}; +use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; +use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; +use dash_sdk::platform::proto::{BroadcastStateTransitionRequest, ResponseMetadata}; +use dash_sdk::platform::types::identity::PublicKeyHash; +use dash_sdk::platform::{DocumentQuery, Fetch, FetchMany}; +use dash_sdk::query_types::{Contenders, Documents, IdentityContractNonceFetcher}; +use dash_sdk::{Error, ProofVerifierError, Sdk}; + +use crate::client::Client; +use crate::ffi::{self, Status, StatusKind}; +use crate::provider; + +/// Documents per page; Drive's query limit. +pub const PAGE_SIZE: u32 = 100; +/// Contenders requested from a contested-name vote state. +const CONTESTED_VOTE_COUNT: u16 = 100; +/// The one parent domain DPNS names live under. +const DPNS_PARENT_DOMAIN: &str = "dash"; +/// `normalizedLabel.maxLength` in the DPNS contract. +const MAX_LABEL_CHARS: usize = 63; +/// Largest state transition the bridge submits; dpp refuses to deserialize +/// anything above its own 100 KiB limit, so this only bounds a caller bug. +const MAX_STATE_TRANSITION_BYTES: usize = 100 * 1024; + +/// A `Verified*` result of the bridge: a status and the verified metadata +/// around a value. +pub trait Verified: Default { + fn with_status(status: Status) -> Self { + let mut result = Self::default(); + *result.status_mut() = status; + result + } + fn status_mut(&mut self) -> &mut Status; + fn meta_mut(&mut self) -> &mut ffi::Meta; +} + +macro_rules! verified { + ($($result:ty),*) => { + $(impl Verified for $result { + fn status_mut(&mut self) -> &mut Status { + &mut self.status + } + fn meta_mut(&mut self) -> &mut ffi::Meta { + &mut self.meta + } + })* + }; +} + +verified!( + ffi::VerifiedIdentity, + ffi::VerifiedU64, + ffi::VerifiedDpnsName, + ffi::VerifiedDpnsNames, + ffi::VerifiedProfile, + ffi::VerifiedContactRequests, + ffi::VerifiedContested +); + +/// The result a panicking bridge read reports. +pub fn failed(message: String) -> R { + R::with_status(Status::internal(message)) +} + +fn meta(metadata: &ResponseMetadata) -> ffi::Meta { + ffi::Meta { + height: metadata.height, + core_chain_locked_height: metadata.core_chain_locked_height, + time_ms: metadata.time_ms, + protocol_version: metadata.protocol_version, + chain_id: metadata.chain_id.clone(), + } +} + +/// Classifies an SDK error by its innermost variant. +pub fn classify(error: &Error) -> Status { + let kind = match error { + Error::NoAvailableAddressesToRetry(inner) => return classify(inner), + // The provider refuses inside proof verification (wrapped in + // `Proof`) or on a direct contract lookup; same mapping either way. + Error::ContextProviderError(inner) + | Error::Proof(ProofVerifierError::ContextProviderError(inner)) => { + provider::status_kind(inner) + } + Error::Proof(_) | Error::DriveProofError(..) | Error::InvalidProvedResponse(_) => { + StatusKind::Rejected + } + Error::StaleNode(_) => StatusKind::Rejected, + // The embedder's proxy failed: no node was asked, so this is an + // outage, not a refusal (the SDK neither retried nor banned). + Error::DapiClientError(DapiClientError::Transport(transport)) + if transport.is_proxy_failure() => + { + StatusKind::Unavailable + } + // A definitive (non-retryable) gRPC refusal: the node answered and + // said no, without a consensus error; nothing was retried. + Error::DapiClientError(DapiClientError::Transport(TransportError::Grpc(status))) + if !status.can_retry() => + { + StatusKind::Rejected + } + Error::DapiClientError(DapiClientError::Transport(_)) + | Error::DapiClientError(DapiClientError::NoAvailableAddresses) + | Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(_)) + | Error::TimeoutReached(..) + | Error::Cancelled(_) => StatusKind::Unavailable, + Error::AlreadyExists(_) => StatusKind::AlreadyExists, + Error::Protocol(ProtocolError::ConsensusError(consensus)) => { + return Status { + kind: StatusKind::Consensus, + consensus_code: consensus.code(), + message: consensus.to_string(), + } + } + Error::StateTransitionBroadcastError(broadcast) => { + return Status { + kind: StatusKind::Consensus, + consensus_code: broadcast.code, + message: broadcast.message.clone(), + } + } + _ => StatusKind::Internal, + }; + Status::new(kind, error.to_string()) +} + +/// The shell's post-verification checks, in order: the chain id, then the +/// height watermark (so a foreign chain never moves it), then the verified +/// protocol version the builders use, then the protocol-version signal, +/// which still returns the value. +fn accept(client: &Client, metadata: &ResponseMetadata) -> Result { + if metadata.chain_id != client.tenderdash_chain_id() { + return Err(Status::new( + StatusKind::ChainIdMismatch, + format!( + "response signed for tenderdash chain {:?}, expected {:?}", + metadata.chain_id, + client.tenderdash_chain_id() + ), + )); + } + if !client.observe_height(metadata.height) { + return Err(Status::new( + StatusKind::Rejected, + format!( + "stale response: platform height {} trails the highest verified height {} by \ + more than {} blocks", + metadata.height, + client.last_seen_height(), + crate::client::HEIGHT_TOLERANCE + ), + )); + } + client.observe_protocol_version(metadata.protocol_version); + if metadata.protocol_version > LATEST_VERSION { + return Ok(Status::new( + StatusKind::UnsupportedProtocolVersion, + format!( + "the network runs protocol version {}, this build knows up to {LATEST_VERSION}", + metadata.protocol_version + ), + )); + } + Ok(Status::ok()) +} + +/// Runs one metadata-returning SDK operation and applies [`accept`] to the +/// verified metadata. `fill` turns the value into the bridge result, whose +/// status and metadata are set here; on `None` (proven absence) the status +/// is `ProvenAbsent`, also under an unsupported protocol version, which +/// [`accept`] has recorded by then (the builders refuse) and which +/// `meta.protocol_version` shows; the default value could not tell absence +/// apart. A proven absence is as authenticated as a value and carries the +/// same metadata. +fn fetch(client: &Client, op: F, fill: impl FnOnce(T) -> Result) -> R +where + R: Verified, + F: FnOnce(Sdk) -> Fut, + Fut: Future, ResponseMetadata), Error>> + Send + 'static, + T: Send + 'static, +{ + let attempt = || -> Result { + client.check_ready_for_proofs()?; + let (value, metadata) = client.run(op)?.map_err(|error| classify(&error))?; + let status = accept(client, &metadata)?; + let (mut result, status) = match value { + Some(value) => (fill(value).map_err(Status::internal)?, status), + None => ( + R::default(), + Status::new(StatusKind::ProvenAbsent, "proven absent"), + ), + }; + *result.meta_mut() = meta(&metadata); + *result.status_mut() = status; + Ok(result) + }; + attempt().unwrap_or_else(R::with_status) +} + +// --- identities ------------------------------------------------------------ + +fn key_bounds(bounds: Option<&ContractBounds>) -> ffi::ContractBounds { + match bounds { + None => ffi::ContractBounds::default(), + Some(ContractBounds::SingleContract { id }) => ffi::ContractBounds { + kind: ffi::BoundsKind::SingleContract, + contract_id: id.to_buffer(), + document_type: String::new(), + }, + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => ffi::ContractBounds { + kind: ffi::BoundsKind::SingleContractDocumentType, + contract_id: id.to_buffer(), + document_type: document_type_name.clone(), + }, + Some(ContractBounds::ContractGroup { id }) => ffi::ContractBounds { + kind: ffi::BoundsKind::ContractGroup, + contract_id: id.to_buffer(), + document_type: String::new(), + }, + } +} + +fn identity_key(key: &IdentityPublicKey) -> ffi::IdentityKey { + ffi::IdentityKey { + id: key.id(), + purpose: key.purpose() as u8, + security_level: key.security_level() as u8, + key_type: key.key_type() as u8, + read_only: key.read_only(), + data: key.data().to_vec(), + disabled_at: key.disabled_at().unwrap_or(0), + bounds: key_bounds(key.contract_bounds()), + } +} + +fn verified_identity(identity: Identity) -> Result { + Ok(ffi::VerifiedIdentity { + value: ffi::Identity { + id: identity.id().to_buffer(), + balance: identity.balance(), + revision: identity.revision(), + keys: identity.public_keys().values().map(identity_key).collect(), + }, + ..Default::default() + }) +} + +pub fn get_identity(client: &Client, id: [u8; 32]) -> ffi::VerifiedIdentity { + let id = Identifier::from(id); + fetch( + client, + move |sdk| async move { Identity::fetch_with_metadata(&sdk, id, None).await }, + verified_identity, + ) +} + +pub fn get_identity_by_pubkey_hash(client: &Client, hash: [u8; 20]) -> ffi::VerifiedIdentity { + fetch( + client, + move |sdk| async move { Identity::fetch_with_metadata(&sdk, PublicKeyHash(hash), None).await }, + verified_identity, + ) +} + +/// The nonce masked with `IDENTITY_NONCE_VALUE_FILTER`, as the SDK's own +/// nonce cache does: the high bits record missing revisions, not the value. +/// `ProvenAbsent` = the identity has not used the contract yet, i.e. 0. +pub fn get_identity_contract_nonce( + client: &Client, + id: [u8; 32], + contract_id: [u8; 32], +) -> ffi::VerifiedU64 { + let query = (Identifier::from(id), Identifier::from(contract_id)); + fetch( + client, + move |sdk| async move { + IdentityContractNonceFetcher::fetch_with_metadata(&sdk, query, None).await + }, + |fetcher: IdentityContractNonceFetcher| { + Ok(ffi::VerifiedU64 { + value: fetcher.0 & IDENTITY_NONCE_VALUE_FILTER, + ..Default::default() + }) + }, + ) +} + +// --- documents ------------------------------------------------------------- + +/// A document query over a compiled-in system contract, loaded at this +/// build's latest protocol version rather than the one verified responses +/// have shown the network to run. The contract only shapes the query and +/// decodes the proved documents: its index definitions are the same in +/// every version of the two contracts, the properties a newer version adds +/// are optional and trail the older ones, so a newer contract reads every +/// older document (format 2 stops at the end of the bytes; format 3 also +/// carries the version stamp), while an older contract refuses a format-3 +/// document stamped with properties it does not know. The SDK seeds its +/// version at the network floor and only ratchets on a verified response, +/// so the floor's contract could not decode the first profile a newer +/// network serves. Builders take the verified version instead. +fn document_query( + contract: SystemDataContract, + document_type: &str, +) -> Result { + let contract = load_system_data_contract(contract, PlatformVersion::latest()) + .map_err(|e| Status::internal(format!("unable to load the system contract: {e}")))?; + DocumentQuery::new(contract, document_type) + .map_err(|e| Status::internal(format!("{document_type} query: {e}"))) +} + +fn clause(field: &str, operator: WhereOperator, value: Value) -> WhereClause { + WhereClause { + field: field.to_string(), + operator, + value, + } +} + +fn equals(field: &str, value: Value) -> WhereClause { + clause(field, WhereOperator::Equal, value) +} + +fn ascending(field: &str) -> OrderClause { + OrderClause { + field: field.to_string(), + ascending: true, + } +} + +/// A query continuing after the document `cursor`, if any. +fn start_after(mut query: DocumentQuery, cursor: Option<[u8; 32]>) -> DocumentQuery { + query.start = cursor.map(|id| Start::StartAfter(id.to_vec())); + query +} + +/// The proved documents of a result, in query order; a proven absence +/// comes back as entries whose document is `None`. +fn present(documents: Documents) -> Vec { + documents.into_values().flatten().collect() +} + +/// One page of documents in query order, plus the cursor for the next page. +/// A page that fills the query's limit is reported as having more: the +/// caller stops when a page comes back short, one extra request at most. +fn page( + client: &Client, + query: Result, + decode: fn(&Document) -> Result, + fill: impl FnOnce(Vec, ffi::Page) -> R, +) -> R +where + R: Verified, +{ + let query = match query { + Ok(query) => query, + Err(status) => return R::with_status(status), + }; + let limit = query.limit as usize; + fetch( + client, + move |sdk| async move { + Document::fetch_many_with_metadata(&sdk, query, None) + .await + .map(|(documents, metadata)| (Some(present(documents)), metadata)) + }, + |documents| { + let page = ffi::Page { + next_start_after: documents + .last() + .map(|document| document.id().to_buffer()) + .unwrap_or_default(), + has_more: documents.len() >= limit, + }; + let items = documents + .iter() + .map(decode) + .collect::, _>>()?; + Ok(fill(items, page)) + }, + ) +} + +/// The single document of a `limit(1)` query, or a proven absence. +fn single( + client: &Client, + query: Result, + decode: fn(&Document) -> Result, + fill: impl FnOnce(D) -> R, +) -> R +where + R: Verified, +{ + let query = match query { + Ok(query) => query.with_limit(1), + Err(status) => return R::with_status(status), + }; + fetch( + client, + move |sdk| async move { + Document::fetch_many_with_metadata(&sdk, query, None) + .await + .map(|(documents, metadata)| (present(documents).into_iter().next(), metadata)) + }, + |document| decode(&document).map(fill), + ) +} + +fn text(document: &Document, name: &str) -> String { + document + .properties() + .get(name) + .and_then(Value::as_text) + .map(str::to_string) + .unwrap_or_default() +} + +fn bytes(document: &Document, name: &str) -> Vec { + document + .properties() + .get(name) + .and_then(|value| value.to_bytes().ok()) + .unwrap_or_default() +} + +fn u32_field(document: &Document, name: &str) -> Result { + document + .properties() + .get(name) + .map(|value| { + value + .to_integer::() + .map_err(|e| format!("property {name} is not a u32: {e}")) + }) + .unwrap_or(Ok(0)) +} + +fn dpns_name(document: &Document) -> Result { + let identity = document + .get("records.identity") + .ok_or("the domain document has no records.identity")? + .to_identifier() + .map_err(|e| format!("records.identity: {e}"))?; + Ok(ffi::DpnsName { + label: text(document, "label"), + normalized_label: text(document, "normalizedLabel"), + parent: text(document, "normalizedParentDomainName"), + identity: identity.to_buffer(), + document_id: document.id().to_buffer(), + owner: document.owner_id().to_buffer(), + }) +} + +fn profile(document: &Document) -> Result { + Ok(ffi::Profile { + document_id: document.id().to_buffer(), + owner: document.owner_id().to_buffer(), + revision: document.revision().unwrap_or(0), + display_name: text(document, "displayName"), + public_message: text(document, "publicMessage"), + avatar_url: text(document, "avatarUrl"), + avatar_hash: bytes(document, "avatarHash"), + avatar_fingerprint: bytes(document, "avatarFingerprint"), + core_payment_address: bytes(document, "corePaymentAddress"), + platform_payment_address: bytes(document, "platformPaymentAddress"), + shielded_address: bytes(document, "shieldedAddress"), + created_at: document.created_at().unwrap_or(0), + updated_at: document.updated_at().unwrap_or(0), + }) +} + +fn contact_request(document: &Document) -> Result { + let to_user_id = document + .properties() + .get("toUserId") + .ok_or("the contact request has no toUserId")? + .to_identifier() + .map_err(|e| format!("toUserId: {e}"))?; + Ok(ffi::ContactRequest { + document_id: document.id().to_buffer(), + owner: document.owner_id().to_buffer(), + to_user_id: to_user_id.to_buffer(), + encrypted_public_key: bytes(document, "encryptedPublicKey"), + sender_key_index: u32_field(document, "senderKeyIndex")?, + recipient_key_index: u32_field(document, "recipientKeyIndex")?, + account_reference: u32_field(document, "accountReference")?, + encrypted_account_label: bytes(document, "encryptedAccountLabel"), + auto_accept_proof: bytes(document, "autoAcceptProof"), + created_at: document.created_at().unwrap_or(0), + core_height_created_at: document.created_at_core_block_height().unwrap_or(0), + }) +} + +/// The DPNS `domain` query under the "dash" parent, i.e. the +/// (normalizedParentDomainName, normalizedLabel) unique index. +fn dash_tld_query() -> Result { + Ok( + document_query(SystemDataContract::DPNS, "domain")?.with_where(equals( + "normalizedParentDomainName", + Value::Text(DPNS_PARENT_DOMAIN.to_string()), + )), + ) +} + +fn names(client: &Client, query: Result) -> ffi::VerifiedDpnsNames { + page(client, query, dpns_name, |items, page| { + ffi::VerifiedDpnsNames { + items, + page, + ..Default::default() + } + }) +} + +/// Resolves a normalized label under "dash"; `ProvenAbsent` = unregistered. +pub fn resolve_name(client: &Client, normalized_label: &str) -> ffi::VerifiedDpnsName { + let query = dash_tld_query().map(|query| { + query.with_where(equals( + "normalizedLabel", + Value::Text(convert_to_homograph_safe_chars(normalized_label)), + )) + }); + single(client, query, dpns_name, |value| ffi::VerifiedDpnsName { + value, + ..Default::default() + }) +} + +/// One page of the names whose normalized label starts with `prefix`, +/// ascending, `limit` clamped to one page. An empty prefix is a query Drive +/// refuses and one longer than a label can be matches nothing: both are +/// refused here, before anything is sent. +pub fn search_names( + client: &Client, + prefix: &str, + limit: u32, + cursor: Option<[u8; 32]>, +) -> ffi::VerifiedDpnsNames { + let prefix = convert_to_homograph_safe_chars(prefix); + if prefix.is_empty() || prefix.chars().count() > MAX_LABEL_CHARS { + return ffi::VerifiedDpnsNames::with_status(Status::internal(format!( + "a name search needs a prefix of 1 to {MAX_LABEL_CHARS} characters" + ))); + } + let query = dash_tld_query().map(|query| { + start_after(query, cursor) + .with_where(clause( + "normalizedLabel", + WhereOperator::StartsWith, + Value::Text(prefix), + )) + .with_order_by(ascending("normalizedLabel")) + .with_limit(limit.clamp(1, PAGE_SIZE)) + }); + names(client, query) +} + +/// One page of the names whose `records.identity` is `identity`. The +/// contract's `identityId` index has that one property, so Drive orders +/// the entries under it by document id, which is the order the cursor +/// continues. Up to protocol version 13 Drive answers a continuation on +/// this index with a proven empty page (it skips every remaining entry +/// under the identity), which would read as the end of the list: a +/// continuation answered there is `Unavailable` instead, so an identity +/// with more than one page of names is not silently cut off. +pub fn names_of_identity( + client: &Client, + identity: [u8; 32], + cursor: Option<[u8; 32]>, +) -> ffi::VerifiedDpnsNames { + let query = document_query(SystemDataContract::DPNS, "domain").map(|query| { + start_after(query, cursor) + .with_where(equals("records.identity", Value::Identifier(identity))) + .with_limit(PAGE_SIZE) + }); + let mut result = names(client, query); + if cursor.is_some() + && result.status.kind == StatusKind::Ok + && result.meta.protocol_version < PROTOCOL_VERSION_14 + { + result.status = Status::unavailable(format!( + "protocol version {} cannot continue a names_of_identity page (fixed in {})", + result.meta.protocol_version, PROTOCOL_VERSION_14 + )); + } + result +} + +/// The DashPay profile owned by `owner`; `ProvenAbsent` = none. +pub fn get_profile(client: &Client, owner: [u8; 32]) -> ffi::VerifiedProfile { + let query = document_query(SystemDataContract::Dashpay, "profile") + .map(|query| query.with_where(equals("$ownerId", Value::Identifier(owner)))); + single(client, query, profile, |value| ffi::VerifiedProfile { + value, + ..Default::default() + }) +} + +/// One page of the contact requests sent to (`to_me`) or by `identity`, +/// created after `since_ms`, oldest first. The `$createdAt` order pins the +/// contract's `(field, $createdAt)` index, which a bare equality would not. +pub fn get_contact_requests( + client: &Client, + identity: [u8; 32], + to_me: bool, + since_ms: u64, + cursor: Option<[u8; 32]>, +) -> ffi::VerifiedContactRequests { + let query = document_query(SystemDataContract::Dashpay, "contactRequest").map(|query| { + let mut query = start_after(query, cursor).with_where(equals( + if to_me { "toUserId" } else { "$ownerId" }, + Value::Identifier(identity), + )); + if since_ms > 0 { + query = query.with_where(clause( + "$createdAt", + WhereOperator::GreaterThan, + Value::U64(since_ms), + )); + } + query + .with_order_by(ascending("$createdAt")) + .with_limit(PAGE_SIZE) + }); + page(client, query, contact_request, |items, page| { + ffi::VerifiedContactRequests { + items, + page, + ..Default::default() + } + }) +} + +// --- contested names ------------------------------------------------------- + +/// A finished or empty contest is a `Contenders` with no contenders; the +/// SDK folds a proven-absent contest into the same shape, so absence is +/// read off the tallies: a real contest always carries them. +fn contested_state(contenders: Contenders) -> Option { + if contenders.contenders.is_empty() + && contenders.winner.is_none() + && contenders.abstain_vote_tally.is_none() + && contenders.lock_vote_tally.is_none() + { + return None; + } + let mut state = ffi::ContestedState { + contenders: contenders + .contenders + .iter() + .map(|(id, contender)| ffi::Contender { + identity: id.to_buffer(), + votes: contender.vote_tally().unwrap_or(0), + has_votes: contender.vote_tally().is_some(), + }) + .collect(), + abstain: contenders.abstain_vote_tally.unwrap_or(0), + lock: contenders.lock_vote_tally.unwrap_or(0), + ..Default::default() + }; + if let Some((winner, finalization_block)) = contenders.winner { + state.ends_at = finalization_block.time_ms; + match winner { + ContestedDocumentVotePollWinnerInfo::WonByIdentity(id) => { + state.winner_kind = ffi::WinnerKind::WonByIdentity; + state.winner = id.to_buffer(); + } + ContestedDocumentVotePollWinnerInfo::Locked => { + state.winner_kind = ffi::WinnerKind::Locked; + } + ContestedDocumentVotePollWinnerInfo::NoWinner => {} + } + } + Some(state) +} + +/// The vote state of the contested name `normalized_label` (tallies, +/// including abstain and lock); `ProvenAbsent` = no contest. +pub fn get_contested_vote_state(client: &Client, normalized_label: &str) -> ffi::VerifiedContested { + let query = ContestedDocumentVotePollDriveQuery { + vote_poll: ContestedDocumentResourceVotePoll { + contract_id: SystemDataContract::DPNS.id(), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text(DPNS_PARENT_DOMAIN.to_string()), + Value::Text(convert_to_homograph_safe_chars(normalized_label)), + ], + }, + result_type: ContestedDocumentVotePollDriveQueryResultType::VoteTally, + allow_include_locked_and_abstaining_vote_tally: true, + start_at: None, + limit: Some(CONTESTED_VOTE_COUNT), + offset: None, + }; + fetch( + client, + move |sdk| async move { + ContenderWithSerializedDocument::fetch_many_with_metadata(&sdk, query, None) + .await + .map(|(contenders, metadata)| (contested_state(contenders), metadata)) + }, + |value| { + Ok(ffi::VerifiedContested { + value, + ..Default::default() + }) + }, + ) +} + +// --- broadcast ------------------------------------------------------------- + +/// The typed outcome of one broadcast attempt. The SDK's error conversion +/// decodes the consensus error DAPI attaches as gRPC metadata and the +/// `AlreadyExists` code; [`classify`] does the rest. +pub fn broadcast_status(result: Result<(), DapiClientError>) -> Status { + match result { + Ok(()) => Status::ok(), + Err(error) => classify(&Error::from(error)), + } +} + +/// Submits `request` through `executor` (the SDK, or a mock transport in +/// tests). A node that rejects the transition is not a failing node, so +/// banning is off. +pub async fn submit( + executor: &E, + request: BroadcastStateTransitionRequest, +) -> Status { + let settings = RequestSettings { + ban_failed_address: Some(false), + ..RequestSettings::default() + }; + broadcast_status( + request + .execute(executor, settings) + .await + .map(|_| ()) + .map_err(|execution| execution.inner), + ) +} + +/// Submits a signed state transition as a raw DAPI request (the SDK's +/// broadcast helper needs the deserialized transition to refresh nonces, +/// which the embedder owns). +pub fn broadcast(client: &Client, state_transition: &[u8]) -> ffi::BroadcastResult { + if state_transition.len() > MAX_STATE_TRANSITION_BYTES { + return ffi::BroadcastResult { + status: Status::internal(format!( + "state transition is {} bytes, above the {MAX_STATE_TRANSITION_BYTES}-byte limit", + state_transition.len() + )), + }; + } + let request = BroadcastStateTransitionRequest { + state_transition: state_transition.to_vec(), + }; + let status = match client.run(move |sdk| async move { submit(&sdk, request).await }) { + Ok(status) | Err(status) => status, + }; + ffi::BroadcastResult { status } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::block::block_info::BlockInfo; + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; + use dash_sdk::dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; + use dash_sdk::dpp::platform_value::platform_value; + use dash_sdk::dpp::version::PlatformVersion; + + /// The `document_query` premise: a document written under an older + /// protocol version and contract (PV13, DashPay v1, document format 2) + /// decodes with the latest compiled-in contract (DashPay v2 under PV14), + /// so a query built on the latest contract reads every network. + #[test] + fn older_documents_decode_with_the_latest_contract() { + let older = PlatformVersion::get(13).expect("PV13"); + let latest = PlatformVersion::latest(); + assert!(older.protocol_version < latest.protocol_version); + let older_contract = load_system_data_contract(SystemDataContract::Dashpay, older).unwrap(); + let latest_contract = + load_system_data_contract(SystemDataContract::Dashpay, latest).unwrap(); + assert!(older_contract.version() < latest_contract.version()); + let older_type = older_contract.document_type_for_name("profile").unwrap(); + let document = older_type + .create_document_from_data( + platform_value!({ + "displayName": "Alice", + "publicMessage": "hello", + "$createdAt": 1_700_000_000_000u64, + "$updatedAt": 1_700_000_000_000u64, + }), + Identifier::from([7u8; 32]), + 1, + 1, + [9u8; 32], + older, + ) + .unwrap(); + let bytes = document + .serialize(older_type, &older_contract, older) + .unwrap(); + let latest_type = latest_contract.document_type_for_name("profile").unwrap(); + let decoded = Document::from_bytes(&bytes, latest_type, latest).unwrap(); + let profile = profile(&decoded).unwrap(); + assert_eq!(profile.display_name, "Alice"); + assert_eq!(profile.public_message, "hello"); + assert_eq!(profile.revision, 1); + } + + #[test] + fn empty_contenders_read_as_no_contest() { + assert!(contested_state(Contenders::default()).is_none()); + } + + #[test] + fn a_finished_poll_without_contenders_is_still_a_contest() { + let state = contested_state(Contenders { + winner: Some(( + ContestedDocumentVotePollWinnerInfo::Locked, + BlockInfo { + time_ms: 77, + ..Default::default() + }, + )), + contenders: Default::default(), + abstain_vote_tally: Some(0), + lock_vote_tally: Some(3), + }) + .expect("a contest"); + assert_eq!(state.winner_kind, ffi::WinnerKind::Locked); + assert_eq!((state.lock, state.ends_at), (3, 77)); + } + + #[test] + fn zero_tallies_are_a_contest_not_absence() { + assert!(contested_state(Contenders { + winner: None, + contenders: Default::default(), + abstain_vote_tally: Some(0), + lock_vote_tally: Some(0), + }) + .is_some()); + } + + #[test] + fn classification_unwraps_the_innermost_error() { + let inner = Error::DapiClientError(DapiClientError::NoAvailableAddresses); + assert_eq!(classify(&inner).kind, StatusKind::Unavailable); + let wrapped = Error::NoAvailableAddressesToRetry(Box::new(Error::Proof( + dash_sdk::ProofVerifierError::NoProofInResult, + ))); + assert_eq!(classify(&wrapped).kind, StatusKind::Rejected); + assert_eq!( + classify(&Error::AlreadyExists("x".to_string())).kind, + StatusKind::AlreadyExists + ); + assert_eq!( + classify(&Error::ContextProviderError( + dash_sdk::error::ContextProviderError::Config("no anchor".to_string()) + )) + .kind, + StatusKind::Unavailable + ); + assert_eq!( + classify(&Error::ContextProviderError( + dash_sdk::error::ContextProviderError::InvalidQuorum("stale".to_string()) + )) + .kind, + StatusKind::Rejected + ); + assert_eq!( + classify(&Error::Generic("bug".to_string())).kind, + StatusKind::Internal + ); + } + + /// A node that answers with a definitive refusal is not an outage. + #[test] + fn a_definitive_grpc_refusal_is_a_rejection() { + use dash_sdk::dapi_grpc::tonic::{Code, Status as GrpcStatus}; + let grpc = |code| { + Error::DapiClientError(DapiClientError::Transport(TransportError::Grpc( + GrpcStatus::new(code, "no"), + ))) + }; + assert_eq!( + classify(&grpc(Code::InvalidArgument)).kind, + StatusKind::Rejected + ); + assert_eq!(classify(&grpc(Code::OutOfRange)).kind, StatusKind::Rejected); + assert_eq!( + classify(&grpc(Code::Unavailable)).kind, + StatusKind::Unavailable + ); + assert_eq!( + classify(&grpc(Code::DeadlineExceeded)).kind, + StatusKind::Unavailable + ); + } +} diff --git a/packages/rs-platform-cxx/src/provider.rs b/packages/rs-platform-cxx/src/provider.rs new file mode 100644 index 00000000000..131be57365a --- /dev/null +++ b/packages/rs-platform-cxx/src/provider.rs @@ -0,0 +1,315 @@ +// 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. + +//! The push-model [`ContextProvider`] `dash-sdk` verifies proofs against. +//! +//! The verifier asks for the public key of the quorum a proof names before +//! it checks the signature, so everything the embedder knows about the +//! trust anchor is enforced here, ahead of any SDK state moving: the +//! quorum type must be the network's Platform type, the quorum must be one +//! the embedder pushed from its mined final commitments, a local ChainLock +//! height must have been pushed, and the proof's signed core-chain-locked +//! height must not trail it by more than [`MAX_CORE_CHAINLOCK_LAG`]. There +//! is no ceiling: the signed height is inside the signed `StateId`, and a +//! node one ChainLock ahead of this one is honest. +//! +//! Failures map to `Status` by variant, never by message: +//! [`ContextProviderError::Config`] (no local anchor) is `Unavailable`, +//! [`ContextProviderError::InvalidQuorum`] (wrong type, unknown hash, stale) +//! is `Rejected`. +//! +//! Data contracts are the compiled-in DPNS and DashPay system contracts, +//! cached per protocol version with a negative entry for every other id. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, RwLock}; + +use dash_sdk::dpp::data_contract::TokenConfiguration; +use dash_sdk::dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; +use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::error::ContextProviderError; +use dash_sdk::platform::ContextProvider; + +use crate::ffi; +use crate::sync::{read, write}; + +/// Core blocks a proof's signed core-chain-locked height may trail the +/// embedder's own best ChainLock: roughly half a day at 2.5 min/block, +/// generous so normal Platform lag never trips it, small enough that a +/// replayed proof is bounded. +pub const MAX_CORE_CHAINLOCK_LAG: u32 = 288; + +#[derive(Default)] +struct Contracts { + /// (contract id, protocol version) -> the compiled-in contract, or + /// `None` for an id that is not a system contract. + by_id: HashMap<(Identifier, u32), Option>>, +} + +pub struct LocalContextProvider { + platform_llmq_type: u32, + /// quorum hash in proof byte order -> BLS public key. + quorum_keys: RwLock>, + /// The embedder's best ChainLock height; 0 = not pushed yet. + local_core_chain_locked_height: AtomicU32, + contracts: RwLock, +} + +impl LocalContextProvider { + pub fn new(platform_llmq_type: u8) -> Self { + LocalContextProvider { + platform_llmq_type: u32::from(platform_llmq_type), + quorum_keys: RwLock::new(HashMap::new()), + local_core_chain_locked_height: AtomicU32::new(0), + contracts: RwLock::new(Contracts::default()), + } + } + + /// Replaces the Platform quorum keys with `keys`. The embedder pushes + /// the full active set on every update, so replacement (not merge) + /// keeps rotated-out quorums from verifying proofs forever. The hashes + /// arrive in the embedder's internal `uint256` byte order; proofs carry + /// the reverse (the order `quorum info` prints), so they are reversed + /// once here and C++ never learns the foreign representation. + pub fn set_quorum_keys(&self, keys: &[ffi::QuorumKey]) { + let keys = keys + .iter() + .map(|key| { + let mut hash = key.hash; + hash.reverse(); + (hash, key.pubkey) + }) + .collect(); + *write(&self.quorum_keys) = keys; + } + + /// Records the embedder's best ChainLock height, the anchor of the lag + /// floor. Monotonic. + pub fn set_local_core_chain_locked_height(&self, height: u32) { + self.local_core_chain_locked_height + .fetch_max(height, Ordering::Relaxed); + } + + /// The pushed ChainLock height, 0 before the first push. + pub fn local_core_chain_locked_height(&self) -> u32 { + self.local_core_chain_locked_height.load(Ordering::Relaxed) + } +} + +/// The `Status` kind a provider refusal maps to: the missing anchor is the +/// embedder's state (`Unavailable`), everything else is the response's +/// fault (`Rejected`). +pub fn status_kind(error: &ContextProviderError) -> ffi::StatusKind { + match error { + ContextProviderError::Config(_) => ffi::StatusKind::Unavailable, + _ => ffi::StatusKind::Rejected, + } +} + +impl ContextProvider for LocalContextProvider { + fn get_data_contract( + &self, + id: &Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + let cache_key = (*id, platform_version.protocol_version); + if let Some(cached) = read(&self.contracts).by_id.get(&cache_key) { + return Ok(cached.clone()); + } + let found = match [SystemDataContract::DPNS, SystemDataContract::Dashpay] + .into_iter() + .find(|contract| contract.id() == *id) + { + Some(contract) => Some(Arc::new( + load_system_data_contract(contract, platform_version) + .map_err(|e| ContextProviderError::DataContractFailure(e.to_string()))?, + )), + None => None, + }; + write(&self.contracts) + .by_id + .insert(cache_key, found.clone()); + Ok(found) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Err(ContextProviderError::Generic( + "token configurations are not available through this binding".to_string(), + )) + } + + fn get_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + if quorum_type != self.platform_llmq_type { + return Err(ContextProviderError::InvalidQuorum(format!( + "proof signed by quorum type {quorum_type}; Platform quorums on this network are \ + type {}", + self.platform_llmq_type + ))); + } + let local = self.local_core_chain_locked_height(); + if local == 0 { + return Err(ContextProviderError::Config( + "no local ChainLock anchor pushed yet".to_string(), + )); + } + if local.saturating_sub(core_chain_locked_height) > MAX_CORE_CHAINLOCK_LAG { + return Err(ContextProviderError::InvalidQuorum(format!( + "stale proof: signed core chain locked height {core_chain_locked_height} trails \ + the local ChainLock height {local} by more than {MAX_CORE_CHAINLOCK_LAG} blocks" + ))); + } + read(&self.quorum_keys) + .get(&quorum_hash) + .copied() + .ok_or_else(|| { + ContextProviderError::InvalidQuorum(format!( + "no locally known Platform quorum with hash {}", + hex::encode(quorum_hash) + )) + }) + } + + /// Only `verify_total_credits_in_system` consumes this, a query the + /// embedder never issues. + fn get_platform_activation_height(&self) -> Result { + Err(ContextProviderError::Config( + "the Platform activation height is not available through this binding".to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + + const PLATFORM_LLMQ: u8 = 106; + + fn provider_with_key(hash_core_order: [u8; 32]) -> LocalContextProvider { + let provider = LocalContextProvider::new(PLATFORM_LLMQ); + provider.set_quorum_keys(&[ffi::QuorumKey { + hash: hash_core_order, + pubkey: [7u8; 48], + }]); + provider + } + + #[test] + fn quorum_hash_is_reversed_from_core_order() { + let mut core_order = [0u8; 32]; + core_order[0] = 0xaa; + core_order[31] = 0xbb; + let provider = provider_with_key(core_order); + provider.set_local_core_chain_locked_height(1000); + let mut proof_order = core_order; + proof_order.reverse(); + assert_eq!( + provider + .get_quorum_public_key(u32::from(PLATFORM_LLMQ), proof_order, 1000) + .expect("the pushed key in proof order"), + [7u8; 48] + ); + assert!(matches!( + provider.get_quorum_public_key(u32::from(PLATFORM_LLMQ), core_order, 1000), + Err(ContextProviderError::InvalidQuorum(_)) + )); + } + + #[test] + fn gates_run_before_the_key_lookup() { + let provider = provider_with_key([1u8; 32]); + // No anchor yet: refused as the embedder's fault. + let error = provider + .get_quorum_public_key(u32::from(PLATFORM_LLMQ), [1u8; 32], 5000) + .unwrap_err(); + assert!(matches!(error, ContextProviderError::Config(_))); + assert_eq!(status_kind(&error), ffi::StatusKind::Unavailable); + + provider.set_local_core_chain_locked_height(5000); + // The wrong LLMQ type is refused even for a pushed hash. + let error = provider + .get_quorum_public_key(u32::from(PLATFORM_LLMQ) + 1, [1u8; 32], 5000) + .unwrap_err(); + assert!(matches!(error, ContextProviderError::InvalidQuorum(_))); + assert_eq!(status_kind(&error), ffi::StatusKind::Rejected); + } + + #[test] + fn chainlock_lag_floor_has_no_ceiling() { + let provider = provider_with_key([1u8; 32]); + provider.set_local_core_chain_locked_height(5000); + let lookup = |signed: u32| { + provider.get_quorum_public_key(u32::from(PLATFORM_LLMQ), [1u8; 32], signed) + }; + assert!(lookup(5000 - MAX_CORE_CHAINLOCK_LAG).is_ok()); + assert!(matches!( + lookup(5000 - MAX_CORE_CHAINLOCK_LAG - 1), + Err(ContextProviderError::InvalidQuorum(_)) + )); + // A node one ChainLock ahead of us is honest. + assert!(lookup(5001).is_ok()); + // The anchor never moves backwards. + provider.set_local_core_chain_locked_height(10); + assert_eq!(provider.local_core_chain_locked_height(), 5000); + } + + #[test] + fn replacing_keys_forgets_rotated_out_quorums() { + let provider = provider_with_key([1u8; 32]); + provider.set_local_core_chain_locked_height(1); + provider.set_quorum_keys(&[ffi::QuorumKey { + hash: [2u8; 32], + pubkey: [9u8; 48], + }]); + assert!(provider + .get_quorum_public_key(u32::from(PLATFORM_LLMQ), [1u8; 32], 1) + .is_err()); + assert_eq!( + provider + .get_quorum_public_key(u32::from(PLATFORM_LLMQ), [2u8; 32], 1) + .expect("the replacement key"), + [9u8; 48] + ); + } + + #[test] + fn serves_system_contracts_and_caches_negative_lookups() { + let provider = LocalContextProvider::new(PLATFORM_LLMQ); + let version = PlatformVersion::latest(); + for contract in [SystemDataContract::DPNS, SystemDataContract::Dashpay] { + let served = provider + .get_data_contract(&contract.id(), version) + .unwrap() + .expect("system contract"); + assert_eq!(served.id(), contract.id()); + } + let unknown = Identifier::from([0x33u8; 32]); + assert!(provider + .get_data_contract(&unknown, version) + .unwrap() + .is_none()); + let cached = read(&provider.contracts); + assert_eq!(cached.by_id.len(), 3); + assert!(cached.by_id[&(unknown, version.protocol_version)].is_none()); + } + + #[test] + fn activation_height_is_refused() { + let provider = LocalContextProvider::new(PLATFORM_LLMQ); + assert!(matches!( + provider.get_platform_activation_height(), + Err(ContextProviderError::Config(_)) + )); + } +} diff --git a/packages/rs-platform-cxx/src/runtime.rs b/packages/rs-platform-cxx/src/runtime.rs new file mode 100644 index 00000000000..187041e73ef --- /dev/null +++ b/packages/rs-platform-cxx/src/runtime.rs @@ -0,0 +1,138 @@ +// 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. + +//! The one tokio runtime this crate owns. Every SDK request is spawned on +//! its worker threads and the calling thread blocks on the join handle; the +//! handle is kept so `shutdown` can abort whatever is in flight (nothing in +//! `dash-sdk` observes a cancellation token, so aborting the task is the +//! only way to interrupt a request) before the runtime is stopped with a +//! bounded timeout. + +use std::future::Future; +use std::sync::Mutex; +use std::time::Duration; + +use tokio::runtime::Handle; +use tokio::task::AbortHandle; + +use crate::sync::lock; + +/// Worker threads: one request at a time from the embedder's serial worker, +/// plus the SDK's own background work. +const WORKER_THREADS: usize = 2; +/// GroveDB proof replay recurses deeper than a default thread stack allows. +const WORKER_STACK_SIZE: usize = 16 * 1024 * 1024; +/// How long `shutdown` waits for aborted tasks to unwind. +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); + +/// Why a request did not complete. +#[derive(Debug)] +pub enum RunError { + /// The runtime was shut down before or while the request ran. + ShutDown, + /// The request panicked (a bug, or hostile input reaching a panic). + Panicked(String), +} + +pub struct Runtime { + inner: Mutex>, + in_flight: Mutex>, +} + +impl Runtime { + pub fn new() -> Result { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .thread_name("dash-platform-sdk") + .thread_stack_size(WORKER_STACK_SIZE) + .enable_all() + .build() + .map_err(|e| format!("unable to start the Platform SDK runtime: {e}"))?; + Ok(Runtime { + inner: Mutex::new(Some(runtime)), + in_flight: Mutex::new(Vec::new()), + }) + } + + /// The runtime's handle, `None` once shut down. + pub fn handle(&self) -> Option { + lock(&self.inner) + .as_ref() + .map(|runtime| runtime.handle().clone()) + } + + /// Runs `future` on the worker threads, blocking the calling thread + /// until it completes, is aborted by [`Self::shutdown`], or panics. + pub fn run(&self, future: F) -> Result + where + F: Future + Send + 'static, + T: Send + 'static, + { + let handle = self.handle().ok_or(RunError::ShutDown)?; + let task = handle.spawn(future); + { + let mut in_flight = lock(&self.in_flight); + in_flight.retain(|task| !task.is_finished()); + in_flight.push(task.abort_handle()); + } + match handle.block_on(task) { + Ok(value) => Ok(value), + Err(join) if join.is_cancelled() => Err(RunError::ShutDown), + Err(join) => Err(RunError::Panicked(join.to_string())), + } + } + + /// Aborts every in-flight request and stops the runtime, waiting at + /// most [`SHUTDOWN_TIMEOUT`] for the tasks to unwind. Idempotent. + pub fn shutdown(&self) { + for task in lock(&self.in_flight).drain(..) { + task.abort(); + } + // Taken out from under the lock first: a blocking teardown while + // holding it would stall every concurrent `run`. + let runtime = lock(&self.inner).take(); + if let Some(runtime) = runtime { + runtime.shutdown_timeout(SHUTDOWN_TIMEOUT); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runs_and_shuts_down() { + let runtime = Runtime::new().expect("runtime"); + assert_eq!(runtime.run(async { 41 + 1 }).unwrap(), 42); + runtime.shutdown(); + assert!(matches!(runtime.run(async { 1 }), Err(RunError::ShutDown))); + runtime.shutdown(); + } + + #[test] + fn a_panicking_request_is_reported_not_propagated() { + let runtime = Runtime::new().expect("runtime"); + let error = runtime.run(async { panic!("hostile bytes") }).unwrap_err(); + assert!(matches!(error, RunError::Panicked(message) if message.contains("hostile bytes"))); + } + + #[test] + fn shutdown_interrupts_an_in_flight_request() { + let runtime = std::sync::Arc::new(Runtime::new().expect("runtime")); + let worker = { + let runtime = std::sync::Arc::clone(&runtime); + std::thread::spawn(move || { + runtime.run(async { + tokio::time::sleep(Duration::from_secs(60)).await; + }) + }) + }; + std::thread::sleep(Duration::from_millis(100)); + let started = std::time::Instant::now(); + runtime.shutdown(); + assert!(matches!(worker.join().unwrap(), Err(RunError::ShutDown))); + assert!(started.elapsed() < Duration::from_secs(30)); + } +} diff --git a/packages/rs-platform-cxx/src/signer.rs b/packages/rs-platform-cxx/src/signer.rs new file mode 100644 index 00000000000..f218278f247 --- /dev/null +++ b/packages/rs-platform-cxx/src/signer.rs @@ -0,0 +1,185 @@ +// 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. + +//! The two signer adapters dpp's builders drive over the embedder's +//! `WalletSigner`. +//! +//! [`BytesSigner`] implements dpp's identity-key signer: it forwards the +//! full signable preimage to `SignForKey`, so the embedder hashes the bytes +//! itself and can check what it is signing before it does. The compact +//! signature it returns is exactly what `dashcore::signer::sign` would +//! produce from the raw key. [`AssetLockSigner`] implements `key-wallet`'s +//! digest signer for the asset-lock outpoint key of an identity +//! registration: the only digest path, because that trait offers no +//! preimage. It recovers the public key from the recoverable signature, so +//! the embedder never has to export it. +//! +//! Both traits require `Send + Sync`. The builders are driven to completion +//! on the calling thread by a local executor, so the `WalletSigner` is only +//! ever called on the thread that called the builder; the embedder's signer +//! must nonetheless be safe to call from any thread, which is the documented +//! contract of `WalletSigner` and the basis of its `Send + Sync` impls. + +use async_trait::async_trait; +use dash_sdk::dpp::address_funds::AddressWitness; +use dash_sdk::dpp::dashcore::secp256k1::ecdsa::{RecoverableSignature, RecoveryId}; +use dash_sdk::dpp::dashcore::secp256k1::{ecdsa, Message, PublicKey, Secp256k1}; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::signer::Signer; +use dash_sdk::dpp::identity::{IdentityPublicKey, KeyType}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::key_wallet::signer::{Signer as KeyWalletSigner, SignerMethod}; +use dash_sdk::dpp::platform_value::BinaryData; +use dash_sdk::dpp::ProtocolError; + +use crate::ffi; + +const COMPACT_SIGNATURE_SIZE: usize = 65; + +/// What the builders ask of the embedder's wallet: the two callbacks of the +/// C++ `WalletSigner`, as a Rust trait so tests can supply a wallet without +/// a C++ shim. Both return `false` to refuse; a refusal fails the build. +pub trait SignerCallbacks: Send + Sync { + /// Signs the full signable preimage of a state transition with identity + /// key `key_id`; the wallet hashes it (double SHA256) itself and answers + /// with a 65-byte compact recoverable ECDSA signature. + fn sign_for_key(&self, key_id: u32, signable: &[u8], signature: &mut Vec) -> bool; + /// Signs the 32-byte double SHA256 the builder computed with the + /// asset lock's outpoint key, the same compact form. + fn sign_asset_lock_sighash(&self, sighash: &[u8; 32], signature: &mut Vec) -> bool; +} + +// SAFETY: `WalletSigner` is documented as callable from any thread (its +// callbacks take the wallet's own lock), and the builders only ever call it +// on the thread that called them (`futures::executor::block_on`). The impls +// exist so the signer satisfies dpp's `Send + Sync` signer bounds. +unsafe impl Send for ffi::WalletSigner {} +unsafe impl Sync for ffi::WalletSigner {} + +impl SignerCallbacks for ffi::WalletSigner { + fn sign_for_key(&self, key_id: u32, signable: &[u8], signature: &mut Vec) -> bool { + self.SignForKey(key_id, signable, signature) + } + + fn sign_asset_lock_sighash(&self, sighash: &[u8; 32], signature: &mut Vec) -> bool { + self.SignAssetLockSighash(sighash, signature) + } +} + +/// dpp's signer traits require `Debug`; the embedder's signer is opaque. +impl std::fmt::Debug for dyn SignerCallbacks + '_ { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignerCallbacks") + } +} + +fn compact(signature: Vec, what: &str) -> Result<[u8; COMPACT_SIGNATURE_SIZE], String> { + signature.try_into().map_err(|signature: Vec| { + format!( + "{what}: unexpected signature size {} (want {COMPACT_SIGNATURE_SIZE})", + signature.len() + ) + }) +} + +/// dpp `Signer` over `SignForKey`. +#[derive(Debug)] +pub struct BytesSigner<'a>(pub &'a dyn SignerCallbacks); + +impl BytesSigner<'_> { + fn sign_bytes(&self, key_id: u32, data: &[u8]) -> Result { + let mut signature = Vec::new(); + if !self.0.sign_for_key(key_id, data, &mut signature) { + return Err(format!("the wallet refused to sign with key {key_id}")); + } + compact(signature, &format!("key {key_id}")) + .map(|signature| BinaryData::new(signature.to_vec())) + } +} + +#[async_trait] +impl Signer for BytesSigner<'_> { + async fn sign( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + if !self.can_sign_with(key) { + return Err(ProtocolError::Generic(format!( + "key {} is {:?}; the wallet only produces ECDSA signatures", + key.id(), + key.key_type() + ))); + } + self.sign_bytes(key.id(), data) + .map_err(ProtocolError::Generic) + } + + async fn sign_create_witness( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + let signature = self.sign(key, data).await?; + Ok(AddressWitness::P2pkh { signature }) + } + + /// dpp checks purpose, security level and disabled state, but not the + /// key type; the wallet answers with compact secp256k1 signatures only. + fn can_sign_with(&self, key: &IdentityPublicKey) -> bool { + matches!( + key.key_type(), + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 + ) + } +} + +/// `key_wallet::signer::Signer` over `SignAssetLockSighash`. The derivation +/// path is ignored: the embedder bound the signer to the flow's funding key. +#[derive(Debug)] +pub struct AssetLockSigner<'a>(pub &'a dyn SignerCallbacks); + +#[async_trait] +impl KeyWalletSigner for AssetLockSigner<'_> { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + _path: &DerivationPath, + sighash: [u8; 32], + ) -> Result<(ecdsa::Signature, PublicKey), Self::Error> { + let mut signature = Vec::new(); + if !self.0.sign_asset_lock_sighash(&sighash, &mut signature) { + return Err("the wallet refused to sign the asset lock".to_string()); + } + let signature = compact(signature, "asset lock key")?; + // The compact header is 27 + recovery id + 4 for a compressed key + // (`dashcore::signer::CompactSignature`); the asset lock's outpoint + // key is compressed (Core funds P2PKH of the compressed key), so the + // uncompressed range 27..=30 is refused. + let recovery_id = RecoveryId::try_from(i32::from(signature[0]) - 27 - 4).map_err(|_| { + format!( + "asset lock signature header {:#04x} is not a compressed-key recovery header \ + (31..=34)", + signature[0] + ) + })?; + let recoverable = RecoverableSignature::from_compact(&signature[1..], recovery_id) + .map_err(|e| format!("asset lock signature is malformed: {e}"))?; + let public_key = Secp256k1::new() + .recover_ecdsa(&Message::from_digest(sighash), &recoverable) + .map_err(|e| format!("asset lock signature does not recover a public key: {e}"))?; + Ok((recoverable.to_standard(), public_key)) + } + + /// Never called by `sign_with_core_signer`, and the embedder does not + /// export keys. + async fn public_key(&self, _path: &DerivationPath) -> Result { + Err("the wallet does not export the asset lock public key".to_string()) + } +} diff --git a/packages/rs-platform-cxx/src/sync.rs b/packages/rs-platform-cxx/src/sync.rs new file mode 100644 index 00000000000..cee2853ac52 --- /dev/null +++ b/packages/rs-platform-cxx/src/sync.rs @@ -0,0 +1,21 @@ +// 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. + +//! Poison-tolerant lock guards. A poisoned lock means a bridge call +//! panicked while holding it; every lock in this crate guards plain data +//! with no invariant spanning a write, so the state is kept and served. + +use std::sync::{Mutex, MutexGuard, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +pub fn read(lock: &RwLock) -> RwLockReadGuard<'_, T> { + lock.read().unwrap_or_else(PoisonError::into_inner) +} + +pub fn write(lock: &RwLock) -> RwLockWriteGuard<'_, T> { + lock.write().unwrap_or_else(PoisonError::into_inner) +} + +pub fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} diff --git a/packages/rs-platform-cxx/tests/cxx_smoke.cc b/packages/rs-platform-cxx/tests/cxx_smoke.cc new file mode 100644 index 00000000000..27a9f26a5d8 --- /dev/null +++ b/packages/rs-platform-cxx/tests/cxx_smoke.cc @@ -0,0 +1,247 @@ +// Link-and-run check of the installed interface: the generated bridge +// header, the runtime header it includes, the hand-written WalletSigner and +// the static archive. Exercises one call of each kind the embedder makes: +// construction from a Config (directly and through a SOCKS5 proxy), the +// trust inputs, a read before any endpoint +// is known (a typed Unavailable status, never an exception or abort), a +// read with endpoints but no ChainLock anchor, every builder driven through +// a WalletSigner that declines, and the pure helpers. + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +int fail(const char* what) +{ + std::fprintf(stderr, "cxx_smoke: %s\n", what); + return 1; +} + +bool is_kind(const platform_ffi::Status& status, platform_ffi::StatusKind kind) +{ + return status.kind == kind; +} + +} // namespace + +int main() +{ + platform_ffi::Config cfg; + cfg.network = 1; + cfg.tenderdash_chain_id = "dash-testnet-51"; + cfg.platform_llmq_type = 106; + cfg.proxy.kind = 0; + cfg.proxy.isolate = false; + rust::Box client = platform_ffi::new_platform_client(cfg); + + // A bad config is a rust::Error, not an abort. + bool threw = false; + try { + platform_ffi::Config bad = cfg; + bad.network = 42; + platform_ffi::new_platform_client(bad); + } catch (const rust::Error&) { + threw = true; + } + if (!threw) return fail("bad network accepted"); + + // Through a proxy; one it cannot use is a rust::Error (fail closed). + platform_ffi::Config proxied = cfg; + proxied.proxy.kind = 1; + proxied.proxy.address = "127.0.0.1:9050"; + proxied.proxy.isolate = true; + rust::Box proxied_client = platform_ffi::new_platform_client(proxied); + if (!is_kind(proxied_client->resolve_name("alice").status, platform_ffi::StatusKind::Unavailable)) { + return fail("proxied resolve without endpoints is not Unavailable"); + } + threw = false; + try { + platform_ffi::Config bad = proxied; + bad.proxy.address = "localhost:9050"; + platform_ffi::new_platform_client(bad); + } catch (const rust::Error&) { + threw = true; + } + if (!threw) return fail("proxy host name accepted"); + + const std::array id{}; + const std::array hash160{}; + + // Reads before any endpoint is known report Unavailable; nothing throws. + if (!is_kind(client->get_identity(id).status, platform_ffi::StatusKind::Unavailable)) { + return fail("read without endpoints is not Unavailable"); + } + // A search prefix the network would refuse is refused here, whatever + // the client's state. + if (!is_kind(client->search_names("", 10, id).status, platform_ffi::StatusKind::Internal)) { + return fail("empty search prefix is not refused"); + } + if (!is_kind(client->resolve_name("alice").status, platform_ffi::StatusKind::Unavailable)) { + return fail("resolve without endpoints is not Unavailable"); + } + + // Trust inputs round-trip. The slices view plain C++ containers: the + // bridge takes `&[T]`, so no rust::Vec instantiation is needed. + try { + platform_ffi::QuorumKey key; + for (std::size_t i = 0; i < key.hash.size(); ++i) key.hash[i] = static_cast(i); + for (std::size_t i = 0; i < key.pubkey.size(); ++i) key.pubkey[i] = static_cast(i); + const std::vector keys{key}; + client->set_quorum_keys(rust::Slice(keys.data(), keys.size())); + const std::vector endpoints{rust::String("https://127.0.0.1:1")}; + client->set_endpoints(rust::Slice(endpoints.data(), endpoints.size())); + } catch (const rust::Error& e) { + return fail(e.what()); + } + // Endpoints but no ChainLock anchor yet: proved reads are not + // dispatched at all. + if (!is_kind(client->get_identity(id).status, platform_ffi::StatusKind::Unavailable)) { + return fail("read without a ChainLock anchor is not Unavailable"); + } + client->set_chainlock_height(std::uint32_t{1}); + threw = false; + try { + const std::vector endpoints{rust::String("not a uri")}; + client->set_endpoints(rust::Slice(endpoints.data(), endpoints.size())); + } catch (const rust::Error&) { + threw = true; + } + if (!threw) return fail("malformed endpoint accepted"); + + // With an unreachable endpoint every read is a typed failure. + const auto unreachable = client->get_identity_by_pubkey_hash(hash160); + if (is_kind(unreachable.status, platform_ffi::StatusKind::Ok) || + is_kind(unreachable.status, platform_ffi::StatusKind::ProvenAbsent)) { + return fail("unreachable endpoint produced a result"); + } + + // Before a verified read the network's protocol version is unknown on a + // network whose floor is below this build's latest version: builders and + // the contested fund amount refuse. A devnet's floor is the latest, so + // its builders reach the WalletSigner. + const auto expect_error = [](const char* what, auto&& call) { + try { + call(); + } catch (const rust::Error&) { + return 0; + } + return fail(what); + }; + if (expect_error("contested_vote_fund_credits before a verified read", + [&] { client->contested_vote_fund_credits(); })) + return 1; + platform_ffi::Config devnet_cfg; + devnet_cfg.network = 2; + devnet_cfg.tenderdash_chain_id = "devnet"; + devnet_cfg.platform_llmq_type = 106; + rust::Box devnet = platform_ffi::new_platform_client(devnet_cfg); + if (devnet->contested_vote_fund_credits() == 0) return fail("contested_vote_fund_credits"); + + // A builder round trip through the WalletSigner callback type: the + // wallet declines, every builder surfaces that as rust::Error. + bool signer_called = false; + const platform_ffi::WalletSigner signer( + [&](std::uint32_t, std::span, std::vector&) { + signer_called = true; + return false; + }, + [&](const std::array&, std::vector&) { + signer_called = true; + return false; + }); + platform_ffi::IdentityKey key; + key.id = 1; + key.purpose = 0; + key.security_level = 2; + key.key_type = 0; + key.read_only = false; + key.data.push_back(2); + for (int i = 1; i < 33; ++i) key.data.push_back(0); + key.disabled_at = 0; + key.bounds.kind = platform_ffi::BoundsKind::NoBounds; + + if (expect_error("build_dpns_preorder before a verified read", [&] { + client->build_dpns_preorder(id, std::uint64_t{1}, "alice", id, key, signer); + })) + return 1; + if (expect_error("build_contact_request before a verified read", [&] { + platform_ffi::Identity sender; + sender.id = id; + platform_ffi::ContactRequestInput input; + input.to_user_id = id; + client->build_contact_request(sender, sender, std::uint64_t{1}, input, key, signer); + })) + return 1; + if (signer_called) return fail("the signer was called before the version was verified"); + if (expect_error("build_dpns_preorder", [&] { + devnet->build_dpns_preorder(id, std::uint64_t{1}, "alice", id, key, signer); + })) + return 1; + if (!signer_called) return fail("the devnet builder never reached the signer"); + if (expect_error("build_dpns_domain", [&] { + devnet->build_dpns_domain(id, std::uint64_t{1}, "alice", id, key, signer); + })) + return 1; + if (expect_error("build_profile", [&] { + platform_ffi::Profile existing; + existing.document_id = id; + existing.owner = id; + existing.revision = 1; + platform_ffi::ProfileInput profile; + profile.display_name = "name"; + devnet->build_profile(id, std::uint64_t{1}, existing, profile, key, signer); + })) + return 1; + if (expect_error("build_contact_request", [&] { + platform_ffi::Identity sender; + sender.id = id; + platform_ffi::Identity recipient = sender; + platform_ffi::ContactRequestInput input; + input.to_user_id = id; + input.sender_key_index = 2; + input.recipient_key_index = 2; + input.account_reference = 0; + devnet->build_contact_request(sender, recipient, std::uint64_t{1}, input, key, signer); + })) + return 1; + if (expect_error("build_identity_create", [&] { + platform_ffi::AssetLockProofInput proof; + proof.is_instant = false; + proof.core_chain_locked_height = 1; + const std::vector keys; + devnet->build_identity_create( + proof, rust::Slice(keys.data(), keys.size()), + signer); + })) + return 1; + + // Pure helpers. + if (std::string(platform_ffi::normalize_label("Alice")) != "a11ce") return fail("normalize_label"); + if (!platform_ffi::is_valid_username("alice")) return fail("is_valid_username"); + if (!platform_ffi::is_contested_username("alice")) return fail("is_contested_username"); + if (platform_ffi::credits_per_duff() != 1000) return fail("credits_per_duff"); + const auto dpns = platform_ffi::system_contract_id(platform_ffi::SystemContract::Dpns); + const auto dashpay = platform_ffi::system_contract_id(platform_ffi::SystemContract::Dashpay); + if (dpns == dashpay) return fail("system_contract_id"); + std::array mac{}; + const std::uint32_t reference = platform_ffi::dip15_account_reference_from_mac(mac, 5, 3); + const auto unmasked = platform_ffi::dip15_unmask_account_reference_from_mac(mac, reference); + if (unmasked.version != 3 || unmasked.account_index != 5) return fail("account reference"); + if (platform_ffi::dip15_receive_keys_acceptable(1, 2, 0)) return fail("key 0 accepted"); + if (!platform_ffi::dip15_receive_keys_acceptable(1, 2, 3)) return fail("keys refused"); + + devnet->shutdown(); + client->shutdown(); + client->shutdown(); // idempotent + if (!is_kind(client->get_identity(id).status, platform_ffi::StatusKind::Unavailable)) { + return fail("read after shutdown is not Unavailable"); + } + return 0; +} From 8b4db47ff5d5ca4d48343d26a0a394b16ebad541 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 23 Sep 2026 23:35:53 -0500 Subject: [PATCH 2/5] test(sdk): replay and builder suites for dash-platform-cxx at PV13 and PV14 Every test that depends on the protocol version runs at PV13 (what testnet and mainnet run, the SDK's floor for them) and at this build's latest, parametrized with test_case::test_matrix; the Drive fixture is generated at each version, signed by one test quorum. tests/replay.rs replays the proved reads and the broadcast through the same client code an embedder runs, against dash-sdk's mock transport fed with proofs generated from a real Drive state (tests/common/mod.rs): GroveDB proofs from the state and a Tenderdash StateId/CanonicalVote signed by a test BLS quorum, so the SDK runs the full proof replay and quorum-signature check and only the socket is mocked. It covers the design's freshness matrix (proofless, tampered proof, tampered signature, unknown quorum, quorum hash in Core order, wrong LLMQ type, foreign chain id with no shell state moved while the SDK's ratchet does, no anchor, ChainLock lag 288/289 and one ahead, height watermark H/H-2/H-3 accepted and H-4/H-5 rejected, stale signed time recording no version, PV15 signalled with the value and closing the builders, a proven absence under PV15 still ProvenAbsent), identity by id and by key hash, nonce masking and the proven-absent nonce of an unused contract, resolve/search/names-of-identity, search prefix guards and a definitive gRPC refusal read as Rejected while an outage stays Unavailable, 101-name pages with has_more against the query's limit and a cursor continuation for both paged name reads (a names_of_identity continuation is an empty page at PV13, pinned), a profile with a DashPay v2 payment address where the contract has one and the profile at the network floor, contact requests, contested tallies and absence, every broadcast Status kind, and the lifecycle. tests/builders.rs checks every builder byte for byte against dpp's in-process private-key constructions (identity create against try_from_identity_with_signer_and_private_key for instant and chain proofs, DPNS preorder/domain and profile create against put_to_platform's create path including its id derivation, the profile replace against its replacement path, the contact request against Sdk::create_contact_request and decryption with the same secret), every document's id recomputed as Drive's advanced-structure check does and its data validated against the system contract at that version, the profile replace carrying avatar and payment-address fields it does not edit, refusing another identity's profile and refusing at PV13 a payment address DashPay v1 lacks, the contested prefund, signer and bad-input refusals, public-key recovery for every compressed header, the no-reactor invariant, and test_data/state_transition_first_byte.json (Batch = 2, IdentityCreate = 3), checked against the built transitions and rewritten only under UPDATE_TEST_VECTORS=1. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 7 + packages/rs-platform-cxx/Cargo.toml | 22 + .../state_transition_first_byte.json | 12 + packages/rs-platform-cxx/tests/builders.rs | 1493 +++++++++++++++++ packages/rs-platform-cxx/tests/common/mod.rs | 927 ++++++++++ packages/rs-platform-cxx/tests/replay.rs | 1192 +++++++++++++ 6 files changed, 3653 insertions(+) create mode 100644 packages/rs-platform-cxx/test_data/state_transition_first_byte.json create mode 100644 packages/rs-platform-cxx/tests/builders.rs create mode 100644 packages/rs-platform-cxx/tests/common/mod.rs create mode 100644 packages/rs-platform-cxx/tests/replay.rs diff --git a/Cargo.lock b/Cargo.lock index 271978b07f1..d17082d6bbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1765,10 +1765,17 @@ dependencies = [ "async-trait", "cxx", "cxx-build", + "dash-platform-cxx", "dash-sdk", + "dpp", + "drive", "futures", "hex", "platform-encryption", + "serde_json", + "simple-signer", + "tenderdash-abci", + "test-case", "tokio", "zeroize", ] diff --git a/packages/rs-platform-cxx/Cargo.toml b/packages/rs-platform-cxx/Cargo.toml index 521963c89d0..3b480a8c0bd 100644 --- a/packages/rs-platform-cxx/Cargo.toml +++ b/packages/rs-platform-cxx/Cargo.toml @@ -33,3 +33,25 @@ hex = "0.4" [build-dependencies] cxx-build = "1.0" + +[dev-dependencies] +dash-platform-cxx = { path = ".", features = ["mocks"] } +# The replay suite (`tests/replay.rs`) proves a real Drive state and +# quorum-signs it the way an evonode does; the builder suite +# (`tests/builders.rs`) checks the shell's transitions byte for byte against +# dpp's in-process private-key constructions. +drive = { path = "../rs-drive", default-features = false, features = [ + "full", + "verify", +] } +dpp = { path = "../rs-dpp", default-features = false, features = [ + "bls-signatures", + "fixtures-and-mocks", + "random-public-keys", +] } +tenderdash-abci = { git = "https://github.com/dashpay/rs-tenderdash-abci", tag = "v1.8.0", features = [ + "crypto", +], default-features = false } +simple-signer = { path = "../simple-signer" } +serde_json = "1" +test-case = "3.3.1" diff --git a/packages/rs-platform-cxx/test_data/state_transition_first_byte.json b/packages/rs-platform-cxx/test_data/state_transition_first_byte.json new file mode 100644 index 00000000000..ef8995dc545 --- /dev/null +++ b/packages/rs-platform-cxx/test_data/state_transition_first_byte.json @@ -0,0 +1,12 @@ +{ + "description": "First byte of a serialized StateTransition: bincode's variant index of the StateTransition enum (declaration order in rs-dpp state_transition/mod.rs), not StateTransitionType. Core's WalletSigner asserts it matches the SigningOperation kind before signing.", + "protocol_version": 14, + "batch": { + "first_byte": 2, + "example_hex": "0201777777777777" + }, + "identity_create": { + "first_byte": 3, + "example_hex": "0300030000000000" + } +} diff --git a/packages/rs-platform-cxx/tests/builders.rs b/packages/rs-platform-cxx/tests/builders.rs new file mode 100644 index 00000000000..817980fdaef --- /dev/null +++ b/packages/rs-platform-cxx/tests/builders.rs @@ -0,0 +1,1493 @@ +// 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. + +//! The state-transition builders, checked byte for byte against the +//! constructions `dash-sdk` and dpp perform with in-process private keys: +//! the identity create against `try_from_identity_with_signer_and_private_key` +//! (what `put_to_platform_with_private_key` builds), the DPNS documents +//! against `register_dpns_name`'s inline assembly, and the contact request +//! against `send_contact_request`'s document. The signer stands in for the +//! C++ `WalletSigner`: it receives the signable bytes (or the asset-lock +//! sighash), signs with `dashcore::signer`, and records what it was asked. +//! +//! Every test runs at the protocol version testnet and mainnet run and at +//! this build's latest: the document id derivation, the contested prefund +//! and the DashPay contract all differ between them. +//! +//! The vectors are generated here from the same fixed keys on every run, +//! never hand-written; `first_byte_vectors` checks the one file Core pins +//! against them (`UPDATE_TEST_VECTORS=1` rewrites it). + +mod common; + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use common::{mock_client, offline_sdk, DEPLOYED_VERSION}; +use dash_platform_cxx::builders; +use dash_platform_cxx::ffi::{ + self, AssetLockProofInput, BoundsKind, CompactXpub, ContactRequestInput, ContractBounds, + IdentityKey, NewIdentityKey, ProfileInput, +}; +use dash_platform_cxx::signer::{AssetLockSigner, BytesSigner, SignerCallbacks}; +use dash_sdk::dpp::dashcore::consensus::serialize; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use dash_sdk::dpp::dashcore::signer::{sign, sign_hash}; +use dash_sdk::dpp::dashcore::{Network, OutPoint, PrivateKey, Txid}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dash_sdk::dpp::data_contract::document_type::property_constraints::DocumentSystemValues; +use dash_sdk::dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds as DppBounds; +use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dash_sdk::dpp::identity::signer::Signer; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dash_sdk::dpp::identity::v0::IdentityV0; +use dash_sdk::dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::key_wallet::signer::Signer as KeyWalletSigner; +use dash_sdk::dpp::native_bls::NativeBlsModule; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::prelude::{AssetLockProof, Identifier}; +use dash_sdk::dpp::serialization::{ + PlatformDeserializableUntrusted, PlatformSerializable, Signable, +}; +use dash_sdk::dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +use dash_sdk::dpp::state_transition::batch_transition::batched_transition::document_transition::{ + DocumentTransition, DocumentTransitionV0Methods, +}; +use dash_sdk::dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; +use dash_sdk::dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; +use dash_sdk::dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dash_sdk::dpp::state_transition::batch_transition::BatchTransition; +use dash_sdk::dpp::state_transition::identity_create_transition::methods::IdentityCreateTransitionMethodsV0; +use dash_sdk::dpp::state_transition::identity_create_transition::IdentityCreateTransition; +use dash_sdk::dpp::state_transition::StateTransition; +use dash_sdk::dpp::system_data_contracts::SystemDataContract; +use dash_sdk::dpp::tests::fixtures::instant_asset_lock_proof_fixture; +use dash_sdk::dpp::util::hash::{hash_double, hash_single}; +use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars; +use dash_sdk::dpp::version::{PlatformVersion, ProtocolVersion, LATEST_VERSION}; +use dash_sdk::platform::dashpay::{ + ContactRequestInput as SdkContactRequestInput, EcdhProvider, RecipientIdentity, +}; +use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; +use platform_encryption::{ + compact_xpub_bytes, decrypt_extended_public_key, derive_shared_key_ecdh, +}; +use simple_signer::SingleKeySigner; +use test_case::test_matrix; + +/// Fixed test keys: identity keys 0 (master) and 1 (high), the DIP-15 +/// encryption pair, and the asset-lock outpoint key. +const MASTER_SK: [u8; 32] = [0x11; 32]; +const HIGH_SK: [u8; 32] = [0x22; 32]; +const ENCRYPTION_SK: [u8; 32] = [0x44; 32]; +const ASSET_LOCK_SK: [u8; 32] = [0x33; 32]; + +fn pubkey(secret: &[u8; 32]) -> [u8; 33] { + PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_byte_array(secret).unwrap(), + ) + .serialize() +} + +/// What the wallet was asked to sign, by key id, or `AssetLock`. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Request { + Key(u32, Vec), + AssetLock([u8; 32]), +} + +/// The test wallet: signs with the fixed keys and records every request. +/// It implements the same two callbacks the C++ `WalletSigner` forwards +/// (`SignerCallbacks`), so the builders run exactly as they do under the +/// bridge; `tests/cxx_smoke.cc` covers the C++ side of that forwarding. +struct Wallet { + requests: Mutex>, + refuse: bool, + /// Overrides the asset-lock signature's compact header byte. + signature_header: Option, + tokio_runtime_entered: Mutex, +} + +impl Wallet { + fn new() -> Self { + Wallet { + requests: Mutex::new(Vec::new()), + refuse: false, + signature_header: None, + tokio_runtime_entered: Mutex::new(false), + } + } + + fn refusing() -> Self { + Wallet { + refuse: true, + ..Wallet::new() + } + } + + fn with_signature_header(header: u8) -> Self { + Wallet { + signature_header: Some(header), + ..Wallet::new() + } + } + + fn secret(key_id: u32) -> Option<[u8; 32]> { + match key_id { + 0 => Some(MASTER_SK), + 1 => Some(HIGH_SK), + 2 => Some(ENCRYPTION_SK), + _ => None, + } + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + fn note_thread_state(&self) { + *self.tokio_runtime_entered.lock().unwrap() |= + tokio::runtime::Handle::try_current().is_ok(); + } +} + +impl SignerCallbacks for Wallet { + fn sign_for_key(&self, key_id: u32, signable: &[u8], out: &mut Vec) -> bool { + self.note_thread_state(); + self.requests + .lock() + .unwrap() + .push(Request::Key(key_id, signable.to_vec())); + if self.refuse { + return false; + } + let Some(secret) = Self::secret(key_id) else { + return false; + }; + *out = sign(signable, &secret).expect("sign").to_vec(); + true + } + + fn sign_asset_lock_sighash(&self, sighash: &[u8; 32], out: &mut Vec) -> bool { + self.note_thread_state(); + self.requests + .lock() + .unwrap() + .push(Request::AssetLock(*sighash)); + if self.refuse { + return false; + } + *out = sign_hash(sighash, &ASSET_LOCK_SK).expect("sign").to_vec(); + if let Some(header) = self.signature_header { + out[0] = header; + } + true + } +} + +fn high_key() -> IdentityKey { + IdentityKey { + id: 1, + purpose: Purpose::AUTHENTICATION as u8, + security_level: SecurityLevel::HIGH as u8, + key_type: KeyType::ECDSA_SECP256K1 as u8, + read_only: false, + data: pubkey(&HIGH_SK).to_vec(), + disabled_at: 0, + bounds: ContractBounds::default(), + } +} + +fn dpp_high_key() -> IdentityPublicKey { + builders::identity_key(&high_key()).expect("key") +} + +fn version(protocol_version: ProtocolVersion) -> &'static PlatformVersion { + PlatformVersion::get(protocol_version).expect("known protocol version") +} + +fn owner() -> Identifier { + Identifier::from([0x77u8; 32]) +} + +/// The single document transition of a built batch. +fn document_transition(bytes: &[u8]) -> (StateTransition, Identifier, BTreeMap) { + let state_transition = + StateTransition::deserialize_from_bytes_untrusted(bytes).expect("deserialize"); + let StateTransition::Batch(batch) = &state_transition else { + panic!("not a batch"); + }; + let Some(BatchedTransitionRef::Document(transition)) = batch.first_transition() else { + panic!("no document transition"); + }; + let id = transition.get_id(); + let data = transition.data().cloned().unwrap_or_default(); + (state_transition, id, data) +} + +/// Builds the reference batch for `document` the way `put_to_platform` +/// does for a create without caller entropy (`register_dpns_name`'s path): +/// the id is derived from the entropy and nonce under `version`, then the +/// transition is signed with the in-process high key. +fn reference_create( + version: &'static PlatformVersion, + contract: SystemDataContract, + document_type: &str, + mut document: Document, + entropy: [u8; 32], + nonce: u64, +) -> Vec { + let contract = + dash_sdk::dpp::system_data_contracts::load_system_data_contract(contract, version) + .expect("contract"); + let document_type = contract + .document_type_for_name(document_type) + .expect("type"); + document.set_id( + Document::generate_document_id( + &contract.id(), + &document.owner_id(), + document_type.name(), + &entropy, + nonce, + version, + ) + .expect("document id"), + ); + let signer = SingleKeySigner::new_from_slice(&HIGH_SK, Network::Testnet).expect("signer"); + let transition = futures::executor::block_on( + BatchTransition::new_document_creation_transition_from_document( + document, + document_type, + entropy, + &dpp_high_key(), + nonce, + 0, + None, + &signer, + version, + None, + ), + ) + .expect("reference transition"); + transition.serialize_to_bytes().expect("serialize") +} + +/// What Drive's advanced-structure validation checks on the document +/// transition `bytes` carries at `version`: a create's id is the one it +/// recomputes (`InvalidDocumentTransitionIdError` otherwise), and the data +/// is valid against the system contract at that version. +fn assert_drive_accepts_structure(bytes: &[u8], version: &'static PlatformVersion) { + let (state_transition, id, data) = document_transition(bytes); + let StateTransition::Batch(batch) = &state_transition else { + unreachable!() + }; + let Some(BatchedTransitionRef::Document(transition)) = batch.first_transition() else { + unreachable!() + }; + if let Some(entropy) = transition.entropy() { + let expected = Document::generate_document_id( + &transition.data_contract_id(), + &owner(), + transition.document_type_name(), + &entropy, + transition.identity_contract_nonce(), + version, + ) + .expect("document id"); + assert_ne!(id, Identifier::default(), "the id is set"); + assert_eq!( + id, expected, + "consensus recomputes the id the create carries" + ); + } + let contract = [SystemDataContract::DPNS, SystemDataContract::Dashpay] + .into_iter() + .find(|contract| contract.id() == transition.data_contract_id()) + .expect("a system contract"); + let result = dash_sdk::dpp::system_data_contracts::load_system_data_contract(contract, version) + .expect("contract") + .validate_document_properties( + transition.document_type_name(), + Value::from(data), + &DocumentSystemValues::owned_by(owner()), + version, + ) + .expect("validation runs"); + assert!(result.is_valid(), "{:?}", result.errors); +} + +fn check_built(built: &ffi::Built, reference: &[u8], version: &'static PlatformVersion) { + assert_eq!( + hex::encode(&built.bytes), + hex::encode(reference), + "state transition bytes" + ); + assert_eq!( + built.hash, + hash_single(&built.bytes), + "hash is sha256 of the bytes" + ); + let (_, id, _) = document_transition(&built.bytes); + assert_eq!( + built.object_id, + id.to_buffer(), + "object_id is the document id" + ); + assert_drive_accepts_structure(&built.bytes, version); +} + +// --- DPNS ------------------------------------------------------------------ + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn dpns_preorder_matches_register_dpns_name_for_the_same_salt(protocol_version: ProtocolVersion) { + let version = version(protocol_version); + let salt = [0x55u8; 32]; + let entropy = [0x66u8; 32]; + let wallet = Wallet::new(); + let built = builders::build_dpns_preorder( + version, + owner().to_buffer(), + 7, + "Alice", + &salt, + entropy, + &high_key(), + &wallet, + ) + .expect("preorder"); + // `register_dpns_name`'s document: saltedDomainHash = sha256d(salt ‖ + // normalized label ‖ ".dash"), no other property. + let mut preimage = salt.to_vec(); + preimage.extend_from_slice(b"a11ce.dash"); + let reference_document = Document::V0(DocumentV0 { + owner_id: owner(), + properties: BTreeMap::from([( + "saltedDomainHash".to_string(), + Value::Bytes32(hash_double(preimage)), + )]), + ..Default::default() + }); + let reference = reference_create( + version, + SystemDataContract::DPNS, + "preorder", + reference_document, + entropy, + 7, + ); + check_built(&built, &reference, version); + let requests = wallet.requests(); + assert_eq!(requests.len(), 1); + let Request::Key(1, signable) = &requests[0] else { + panic!("signed with the wrong key: {requests:?}"); + }; + let (state_transition, _, _) = document_transition(&built.bytes); + assert_eq!( + signable, + &state_transition.signable_bytes().unwrap(), + "the wallet saw the signable preimage" + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn dpns_domain_matches_register_dpns_name_for_the_same_salt(protocol_version: ProtocolVersion) { + let version = version(protocol_version); + let salt = [0x55u8; 32]; + let entropy = [0x66u8; 32]; + let wallet = Wallet::new(); + let built = builders::build_dpns_domain( + version, + owner().to_buffer(), + 8, + "Alice", + &salt, + entropy, + &high_key(), + &wallet, + ) + .expect("domain"); + let reference_document = Document::V0(DocumentV0 { + owner_id: owner(), + properties: BTreeMap::from([ + ( + "parentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ( + "normalizedParentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ("label".to_string(), Value::Text("Alice".to_string())), + ( + "normalizedLabel".to_string(), + Value::Text(convert_to_homograph_safe_chars("Alice")), + ), + ("preorderSalt".to_string(), Value::Bytes32(salt)), + ( + "records".to_string(), + Value::Map(vec![( + Value::Text("identity".to_string()), + Value::Identifier(owner().to_buffer()), + )]), + ), + ( + "subdomainRules".to_string(), + Value::Map(vec![( + Value::Text("allowSubdomains".to_string()), + Value::Bool(false), + )]), + ), + ]), + ..Default::default() + }); + let reference = reference_create( + version, + SystemDataContract::DPNS, + "domain", + reference_document, + entropy, + 8, + ); + check_built(&built, &reference, version); + // "a11ce" is contested (3-19 chars of [a-z01-]): dpp attached the + // prefund from the domain type's contested index. + let (state_transition, _, _) = document_transition(&built.bytes); + let StateTransition::Batch(batch) = &state_transition else { + unreachable!() + }; + let Some(BatchedTransitionRef::Document(transition)) = batch.first_transition() else { + unreachable!() + }; + let DocumentTransition::Create(create) = transition else { + panic!("not a create") + }; + assert_eq!( + create + .prefunded_voting_balance() + .as_ref() + .map(|(_, credits)| *credits), + Some(dash_platform_cxx::helpers::contested_vote_fund_credits( + version + )) + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn uncontested_domain_carries_no_prefund(protocol_version: ProtocolVersion) { + let version = version(protocol_version); + let wallet = Wallet::new(); + let built = builders::build_dpns_domain( + version, + owner().to_buffer(), + 1, + "alice-2024", + &[1u8; 32], + [2u8; 32], + &high_key(), + &wallet, + ) + .expect("domain"); + let (state_transition, _, _) = document_transition(&built.bytes); + let StateTransition::Batch(batch) = &state_transition else { + unreachable!() + }; + let Some(BatchedTransitionRef::Document(transition)) = batch.first_transition() else { + unreachable!() + }; + let DocumentTransition::Create(create) = transition else { + panic!("not a create") + }; + assert!(create.prefunded_voting_balance().is_none()); +} + +// --- profile --------------------------------------------------------------- + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn profile_create_and_replace(protocol_version: ProtocolVersion) { + let version = version(protocol_version); + let entropy = [0x66u8; 32]; + let wallet = Wallet::new(); + let input = ProfileInput { + display_name: "Alice".to_string(), + public_message: String::new(), + }; + let built = builders::build_profile( + version, + owner().to_buffer(), + 5, + &ffi::Profile::default(), + &input, + entropy, + &high_key(), + &wallet, + ) + .expect("profile"); + let reference_document = Document::V0(DocumentV0 { + owner_id: owner(), + properties: BTreeMap::from([("displayName".to_string(), Value::Text("Alice".to_string()))]), + ..Default::default() + }); + let reference = reference_create( + version, + SystemDataContract::Dashpay, + "profile", + reference_document, + entropy, + 5, + ); + check_built(&built, &reference, version); + let (_, _, data) = document_transition(&built.bytes); + assert!( + !data.contains_key("publicMessage"), + "empty strings are omitted" + ); + + // The replaced document is the one `get_profile` read, with the edits + // applied: what another wallet set (avatar, payment addresses) and the + // embedder does not edit stays. The payment addresses arrive with + // DashPay v2 (protocol version 14); before, no stored profile has them. + let payment_addresses = dashpay_has_payment_addresses(version); + let payment = |bytes: Vec| if payment_addresses { bytes } else { Vec::new() }; + let existing = ffi::Profile { + document_id: built.object_id, + owner: owner().to_buffer(), + revision: 1, + display_name: "Alice".to_string(), + avatar_url: "https://example.org/a.png".to_string(), + avatar_hash: vec![0x11u8; 32], + avatar_fingerprint: vec![0x22u8; 8], + core_payment_address: payment(vec![0x33u8; 21]), + platform_payment_address: payment(vec![0x44u8; 21]), + shielded_address: payment(vec![0x55u8; 43]), + created_at: 1, + updated_at: 1, + ..Default::default() + }; + let replace = ProfileInput { + display_name: "Alice".to_string(), + public_message: "hello".to_string(), + }; + let replaced = builders::build_profile( + version, + owner().to_buffer(), + 6, + &existing, + &replace, + entropy, + &high_key(), + &wallet, + ) + .expect("replace"); + assert_eq!(replaced.object_id, built.object_id); + let (state_transition, id, data) = document_transition(&replaced.bytes); + assert_eq!(id.to_buffer(), built.object_id); + assert_eq!(data["displayName"], Value::Text("Alice".to_string())); + assert_eq!(data["publicMessage"], Value::Text("hello".to_string())); + assert_eq!( + data["avatarUrl"], + Value::Text("https://example.org/a.png".to_string()) + ); + assert_eq!(data["avatarHash"].to_bytes().unwrap(), vec![0x11u8; 32]); + assert_eq!( + data["avatarFingerprint"].to_bytes().unwrap(), + vec![0x22u8; 8] + ); + if payment_addresses { + assert_eq!( + data["corePaymentAddress"].to_bytes().unwrap(), + vec![0x33u8; 21] + ); + assert_eq!( + data["platformPaymentAddress"].to_bytes().unwrap(), + vec![0x44u8; 21] + ); + assert_eq!( + data["shieldedAddress"].to_bytes().unwrap(), + vec![0x55u8; 43] + ); + assert_eq!(data.len(), 8, "nothing else is added"); + } else { + assert_eq!(data.len(), 5, "nothing else is added"); + } + // Byte for byte what `put_to_platform` sends for a document at a + // revision above the first: a replacement of the sanitized document. + let reference = reference_replace( + version, + Document::V0(DocumentV0 { + id: Identifier::from(built.object_id), + owner_id: owner(), + properties: data.clone(), + revision: Some(2), + ..Default::default() + }), + 6, + ); + assert_eq!(hex::encode(&replaced.bytes), hex::encode(&reference)); + assert_drive_accepts_structure(&replaced.bytes, version); + let StateTransition::Batch(batch) = &state_transition else { + unreachable!() + }; + let Some(BatchedTransitionRef::Document(transition)) = batch.first_transition() else { + unreachable!() + }; + assert_eq!(transition.revision(), Some(2)); + assert_eq!(transition.identity_contract_nonce(), 6); + + // Clearing an edited field drops it; the carried fields stay. + let cleared = builders::build_profile( + version, + owner().to_buffer(), + 7, + &existing, + &ProfileInput { + display_name: String::new(), + public_message: "hello".to_string(), + }, + entropy, + &high_key(), + &wallet, + ) + .expect("clear"); + let (_, _, data) = document_transition(&cleared.bytes); + assert!(!data.contains_key("displayName")); + assert!(data.contains_key("avatarUrl")); + + // A read profile of another identity, or one without a revision, is + // not replaced. + let foreign = ffi::Profile { + owner: [9u8; 32], + ..existing.clone() + }; + assert!(builders::build_profile( + version, + owner().to_buffer(), + 5, + &foreign, + &replace, + entropy, + &high_key(), + &wallet, + ) + .is_err()); + let unrevised = ffi::Profile { + revision: 0, + ..existing.clone() + }; + assert!(builders::build_profile( + version, + owner().to_buffer(), + 5, + &unrevised, + &replace, + entropy, + &high_key(), + &wallet, + ) + .is_err()); + + // A field the network's contract does not have is refused before + // anything is signed, not after Drive charged for it. + if !payment_addresses { + let refused = Wallet::new(); + let error = builders::build_profile( + version, + owner().to_buffer(), + 6, + &ffi::Profile { + core_payment_address: vec![0x33u8; 21], + ..existing.clone() + }, + &replace, + entropy, + &high_key(), + &refused, + ) + .unwrap_err(); + assert!(error.contains("corePaymentAddress"), "{error}"); + assert!(refused.requests().is_empty()); + } +} + +/// Whether the DashPay contract at `version` has the profile payment +/// address fields (DashPay v2, protocol version 14). +fn dashpay_has_payment_addresses(version: &PlatformVersion) -> bool { + dash_sdk::dpp::system_data_contracts::load_system_data_contract( + SystemDataContract::Dashpay, + version, + ) + .expect("contract") + .document_type_for_name("profile") + .expect("profile") + .properties() + .contains_key("corePaymentAddress") +} + +/// The replacement batch `put_to_platform` builds for `document` (already +/// at its new revision) over the DashPay profile type, signed with the +/// in-process high key. +fn reference_replace(version: &'static PlatformVersion, document: Document, nonce: u64) -> Vec { + let contract = dash_sdk::dpp::system_data_contracts::load_system_data_contract( + SystemDataContract::Dashpay, + version, + ) + .expect("contract"); + let document_type = contract.document_type_for_name("profile").expect("type"); + let signer = SingleKeySigner::new_from_slice(&HIGH_SK, Network::Testnet).expect("signer"); + let transition = futures::executor::block_on( + BatchTransition::new_document_replacement_transition_from_document( + document, + document_type, + &dpp_high_key(), + nonce, + 0, + None, + &signer, + version, + None, + ), + ) + .expect("reference transition"); + transition.serialize_to_bytes().expect("serialize") +} + +// --- contact request ------------------------------------------------------- + +fn identity_with_keys( + id: Identifier, + keys: &[(u32, Purpose, SecurityLevel, [u8; 33])], +) -> Identity { + let public_keys = keys + .iter() + .map(|(key_id, purpose, level, data)| { + let bounds = matches!(purpose, Purpose::ENCRYPTION | Purpose::DECRYPTION).then(|| { + DppBounds::SingleContractDocumentType { + id: SystemDataContract::Dashpay.id(), + document_type_name: "contactRequest".to_string(), + } + }); + let key: IdentityPublicKey = IdentityPublicKeyV0 { + id: *key_id, + purpose: *purpose, + security_level: *level, + contract_bounds: bounds, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: data.to_vec().into(), + disabled_at: None, + } + .into(); + (*key_id, key) + }) + .collect(); + IdentityV0 { + id, + public_keys, + balance: 0, + revision: 0, + } + .into() +} + +fn ffi_identity(identity: &Identity) -> ffi::Identity { + ffi::Identity { + id: identity.id().to_buffer(), + balance: identity.balance(), + revision: identity.revision(), + keys: identity + .public_keys() + .values() + .map(|key| IdentityKey { + id: key.id(), + purpose: key.purpose() as u8, + security_level: key.security_level() as u8, + key_type: key.key_type() as u8, + read_only: key.read_only(), + data: key.data().to_vec(), + disabled_at: 0, + bounds: match key.contract_bounds() { + Some(DppBounds::SingleContractDocumentType { + id, + document_type_name, + }) => ContractBounds { + kind: BoundsKind::SingleContractDocumentType, + contract_id: id.to_buffer(), + document_type: document_type_name.clone(), + }, + _ => ContractBounds::default(), + }, + }) + .collect(), + } +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn contact_request_matches_send_contact_request_for_the_same_secret( + protocol_version: ProtocolVersion, +) { + let version = version(protocol_version); + let sender_sk = SecretKey::from_byte_array(&ENCRYPTION_SK).unwrap(); + let recipient_sk = SecretKey::from_byte_array(&[0x88u8; 32]).unwrap(); + let secp = Secp256k1::new(); + let recipient_pk = PublicKey::from_secret_key(&secp, &recipient_sk); + let sender = identity_with_keys( + owner(), + &[ + ( + 0, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + pubkey(&MASTER_SK), + ), + ( + 1, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + pubkey(&HIGH_SK), + ), + ( + 2, + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, + pubkey(&ENCRYPTION_SK), + ), + ], + ); + let recipient = identity_with_keys( + Identifier::from([0x99u8; 32]), + &[ + ( + 0, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + pubkey(&[0x87u8; 32]), + ), + ( + 2, + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, + pubkey(&[0x86u8; 32]), + ), + ( + 3, + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + recipient_pk.serialize(), + ), + ], + ); + // Core derives the ECDH secret and the compact xpub; the shell never + // sees the sender's ENCRYPTION private key. + let shared_secret = derive_shared_key_ecdh(&sender_sk, &recipient_pk); + let xpub = CompactXpub { + parent_fingerprint: [1, 2, 3, 4], + chain_code: [0x5au8; 32], + public_key: pubkey(&[0x5bu8; 32]), + }; + let input = ContactRequestInput { + to_user_id: recipient.id().to_buffer(), + sender_key_index: 2, + recipient_key_index: 3, + recipient_pubkey: recipient_pk.serialize(), + account_reference: 0x1000_0005, + compact_xpub: xpub.clone(), + shared_secret, + account_label: "savings".to_string(), + }; + + let client = mock_client(); + let sdk = offline_sdk(&client, version); + let wallet = Wallet::new(); + let built = builders::build_contact_request( + &sdk, + version, + &ffi_identity(&sender), + &ffi_identity(&recipient), + 9, + &input, + &high_key(), + &wallet, + ) + .expect("contact request"); + let (state_transition, id, data) = document_transition(&built.bytes); + assert_eq!(built.object_id, id.to_buffer()); + assert_eq!(state_transition.signature_public_key_id(), Some(1)); + + // The document is what the SDK mints: the encryption uses a random IV + // per call, so compare structurally and by decrypting with the same + // secret, then compare the transition around it byte for byte by + // rebuilding through the SDK's own path with the minted properties. + assert_eq!( + data["toUserId"], + Value::Identifier(recipient.id().to_buffer()) + ); + assert_eq!(data["senderKeyIndex"], Value::U32(2)); + assert_eq!(data["recipientKeyIndex"], Value::U32(3)); + assert_eq!(data["accountReference"], Value::U32(0x1000_0005)); + let encrypted_key = data["encryptedPublicKey"].to_bytes().unwrap(); + assert_eq!(encrypted_key.len(), 96); + assert_eq!( + decrypt_extended_public_key(&shared_secret, &encrypted_key).unwrap(), + compact_xpub_bytes(xpub.parent_fingerprint, xpub.chain_code, xpub.public_key) + ); + let label = data["encryptedAccountLabel"].to_bytes().unwrap(); + assert!((48..=80).contains(&label.len())); + assert_eq!( + platform_encryption::decrypt_account_label(&shared_secret, &label).unwrap(), + "savings" + ); + + // Same properties and entropy through `send_contact_request`'s + // assembly (the document put path) must give the same bytes. + let StateTransition::Batch(batch) = &state_transition else { + unreachable!() + }; + let Some(BatchedTransitionRef::Document(transition)) = batch.first_transition() else { + unreachable!() + }; + let entropy: [u8; 32] = transition.entropy().unwrap().try_into().unwrap(); + let reference_document = Document::V0(DocumentV0 { + owner_id: sender.id(), + properties: data.clone(), + ..Default::default() + }); + let reference = reference_create( + version, + SystemDataContract::Dashpay, + "contactRequest", + reference_document, + entropy, + 9, + ); + assert_eq!(hex::encode(&built.bytes), hex::encode(&reference)); + assert_drive_accepts_structure(&built.bytes, version); + + // And `create_contact_request` itself, driven with the same inputs + // through the SDK, yields the same property set (modulo the random IVs). + let sdk_input = SdkContactRequestInput { + sender_identity: sender.clone(), + recipient: RecipientIdentity::Identity(recipient.clone()), + sender_key_index: 2, + recipient_key_index: 3, + account_reference: 0x1000_0005, + account_label: Some("savings".to_string()), + auto_accept_proof: None, + }; + type UnusedSdkSide = + fn(&IdentityPublicKey, u32) -> std::future::Ready>; + let ecdh: EcdhProvider = EcdhProvider::ClientSide { + get_shared_secret: move |_: &PublicKey| async move { Ok(shared_secret) }, + }; + let xpub_bytes = + compact_xpub_bytes(xpub.parent_fingerprint, xpub.chain_code, xpub.public_key).to_vec(); + let minted = futures::executor::block_on(sdk.create_contact_request(sdk_input, ecdh, |_| { + let xpub_bytes = xpub_bytes.clone(); + async move { Ok(xpub_bytes) } + })) + .expect("sdk mint"); + let mut expected_keys: Vec<&String> = minted.properties.keys().collect(); + expected_keys.sort(); + let mut built_keys: Vec<&String> = data.keys().collect(); + built_keys.sort(); + assert_eq!(built_keys, expected_keys); + + // The wrong recipient key refuses before anything is signed. + let wrong = ContactRequestInput { + recipient_key_index: 2, + ..input.clone() + }; + let refused = Wallet::new(); + let error = builders::build_contact_request( + &sdk, + version, + &ffi_identity(&sender), + &ffi_identity(&recipient), + 9, + &wrong, + &high_key(), + &refused, + ) + .unwrap_err(); + assert!(error.contains("recipient key"), "{error}"); + assert!(refused.requests().is_empty()); +} + +// --- identity create ------------------------------------------------------- + +fn new_keys() -> Vec { + vec![ + NewIdentityKey { + id: 0, + purpose: Purpose::AUTHENTICATION as u8, + security_level: SecurityLevel::MASTER as u8, + pubkey: pubkey(&MASTER_SK), + bounds: ContractBounds::default(), + }, + NewIdentityKey { + id: 1, + purpose: Purpose::AUTHENTICATION as u8, + security_level: SecurityLevel::HIGH as u8, + pubkey: pubkey(&HIGH_SK), + bounds: ContractBounds::default(), + }, + NewIdentityKey { + id: 2, + purpose: Purpose::ENCRYPTION as u8, + security_level: SecurityLevel::MEDIUM as u8, + pubkey: pubkey(&ENCRYPTION_SK), + bounds: ContractBounds { + kind: BoundsKind::SingleContractDocumentType, + contract_id: SystemDataContract::Dashpay.id().to_buffer(), + document_type: "contactRequest".to_string(), + }, + }, + ] +} + +fn instant_proof() -> AssetLockProof { + let one_time = PrivateKey::from_byte_array(&ASSET_LOCK_SK, Network::Testnet).unwrap(); + instant_asset_lock_proof_fixture(Some(one_time), None) +} + +fn proof_input(proof: &AssetLockProof) -> AssetLockProofInput { + match proof { + AssetLockProof::Instant(instant) => AssetLockProofInput { + is_instant: true, + transaction: serialize(&instant.transaction), + instant_lock: serialize(&instant.instant_lock), + output_index: instant.output_index(), + core_chain_locked_height: 0, + out_point: [0u8; 36], + }, + AssetLockProof::Chain(chain) => AssetLockProofInput { + is_instant: false, + transaction: Vec::new(), + instant_lock: Vec::new(), + output_index: 0, + core_chain_locked_height: chain.core_chain_locked_height, + out_point: chain.out_point.into(), + }, + } +} + +/// A reference signer over all identity keys, for dpp's in-process path. +#[derive(Debug)] +struct AllKeysSigner; + +#[async_trait::async_trait] +impl Signer for AllKeysSigner { + async fn sign( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + let secret = Wallet::secret(key.id()).expect("known key"); + Ok(sign(data, &secret).unwrap().to_vec().into()) + } + async fn sign_create_witness( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + Ok(dash_sdk::dpp::address_funds::AddressWitness::P2pkh { + signature: self.sign(key, data).await?, + }) + } + fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool { + true + } +} + +fn reference_identity_create( + version: &'static PlatformVersion, + proof: AssetLockProof, +) -> (Vec, Identifier) { + let keys = new_keys() + .into_iter() + .map(|key| { + let dpp_key: IdentityPublicKey = IdentityPublicKeyV0 { + id: key.id, + purpose: Purpose::try_from(key.purpose).unwrap(), + security_level: SecurityLevel::try_from(key.security_level).unwrap(), + contract_bounds: (key.bounds.kind == BoundsKind::SingleContractDocumentType).then( + || DppBounds::SingleContractDocumentType { + id: Identifier::from(key.bounds.contract_id), + document_type_name: key.bounds.document_type.clone(), + }, + ), + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: key.pubkey.to_vec().into(), + disabled_at: None, + } + .into(); + (key.id, dpp_key) + }) + .collect(); + let identity_id = proof.create_identifier().unwrap(); + let identity: Identity = IdentityV0 { + id: identity_id, + public_keys: keys, + balance: 0, + revision: 0, + } + .into(); + // `put_to_platform_with_private_key`'s construction. + let transition = futures::executor::block_on( + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( + &identity, + proof, + &ASSET_LOCK_SK, + &AllKeysSigner, + &NativeBlsModule, + 0, + version, + ), + ) + .expect("reference identity create"); + (transition.serialize_to_bytes().unwrap(), identity_id) +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn identity_create_matches_the_private_key_path_byte_for_byte(protocol_version: ProtocolVersion) { + let version = version(protocol_version); + for proof in [ + instant_proof(), + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 2_000_000, + out_point: OutPoint::new(Txid::from_byte_array([0xabu8; 32]), 1), + }), + ] { + let (reference, identity_id) = reference_identity_create(version, proof.clone()); + let wallet = Wallet::new(); + let built = + builders::build_identity_create(version, &proof_input(&proof), &new_keys(), &wallet) + .expect("identity create"); + assert_eq!(hex::encode(&built.bytes), hex::encode(&reference)); + assert_eq!(built.object_id, identity_id.to_buffer()); + assert_eq!(built.hash, hash_single(&built.bytes)); + // Every key signed the same preimage; the asset lock key signed its + // double SHA256, once. + let state_transition = + StateTransition::deserialize_from_bytes_untrusted(&built.bytes).unwrap(); + let signable = state_transition.signable_bytes().unwrap(); + let requests = wallet.requests(); + assert_eq!(requests.len(), 4, "{requests:?}"); + for key_id in 0..3 { + assert!(requests.contains(&Request::Key(key_id, signable.clone()))); + } + assert!(requests.contains(&Request::AssetLock(hash_double(&signable)))); + } +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn identity_create_refuses_bad_input_before_signing(protocol_version: ProtocolVersion) { + let version = version(protocol_version); + let wallet = Wallet::new(); + let proof = proof_input(&instant_proof()); + assert!(builders::build_identity_create(version, &proof, &[], &wallet).is_err()); + let mut duplicated = new_keys(); + duplicated[1].id = 0; + assert!(builders::build_identity_create(version, &proof, &duplicated, &wallet).is_err()); + let garbage = AssetLockProofInput { + transaction: vec![0u8; 8], + ..proof.clone() + }; + assert!(builders::build_identity_create(version, &garbage, &new_keys(), &wallet).is_err()); + assert!(wallet.requests().is_empty(), "nothing was signed"); +} + +// --- signer adapters ------------------------------------------------------- + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn signer_refusals_and_bad_signatures_are_reported(protocol_version: ProtocolVersion) { + let version = version(protocol_version); + let wallet = Wallet::refusing(); + let error = builders::build_dpns_preorder( + version, + owner().to_buffer(), + 1, + "alice", + &[1u8; 32], + [2u8; 32], + &high_key(), + &wallet, + ) + .unwrap_err(); + assert!(error.contains("refused"), "{error}"); + let error = builders::build_identity_create( + version, + &proof_input(&instant_proof()), + &new_keys(), + &wallet, + ) + .unwrap_err(); + assert!(error.contains("refused"), "{error}"); + + // A key the wallet cannot produce ECDSA signatures for is refused + // before the callback. + let wallet = Wallet::new(); + let mut bls_key = high_key(); + bls_key.key_type = KeyType::BLS12_381 as u8; + bls_key.data = vec![0u8; 48]; + let error = builders::build_dpns_preorder( + version, + owner().to_buffer(), + 1, + "alice", + &[1u8; 32], + [2u8; 32], + &bls_key, + &wallet, + ) + .unwrap_err(); + assert!(error.contains("ECDSA"), "{error}"); + assert!(wallet.requests().is_empty()); +} + +#[test] +fn asset_lock_signer_recovers_the_public_key() { + let wallet = Wallet::new(); + let sighash = [0x42u8; 32]; + let (signature, recovered) = futures::executor::block_on( + AssetLockSigner(&wallet).sign_ecdsa(&DerivationPath::master(), sighash), + ) + .expect("asset lock signature"); + let secp = Secp256k1::new(); + let expected = + PublicKey::from_secret_key(&secp, &SecretKey::from_byte_array(&ASSET_LOCK_SK).unwrap()); + assert_eq!(recovered, expected); + // r‖s equals what the compact signature carries. + let compact = sign_hash(&sighash, &ASSET_LOCK_SK).unwrap(); + assert_eq!( + signature.serialize_compact().to_vec(), + compact[1..].to_vec() + ); + assert!(futures::executor::block_on( + AssetLockSigner(&wallet).public_key(&DerivationPath::master()) + ) + .is_err()); + assert!(BytesSigner(&wallet).can_sign_with(&dpp_high_key())); + + // Only a compressed-key recovery header (31..=34) is accepted: the + // asset lock's outpoint key is compressed. + let uncompressed = Wallet::with_signature_header(27); + let error = futures::executor::block_on( + AssetLockSigner(&uncompressed).sign_ecdsa(&DerivationPath::master(), sighash), + ) + .unwrap_err(); + assert!(error.contains("recovery header"), "{error}"); + let garbage = Wallet::with_signature_header(0xff); + assert!(futures::executor::block_on( + AssetLockSigner(&garbage).sign_ecdsa(&DerivationPath::master(), sighash) + ) + .is_err()); + // Every compressed header parses; only the right recovery id recovers + // the key, which is what `sign_with_core_signer` then checks. + let genuine = sign_hash(&sighash, &ASSET_LOCK_SK).unwrap()[0]; + for header in 31..=34u8 { + let wallet = Wallet::with_signature_header(header); + let recovered = futures::executor::block_on( + AssetLockSigner(&wallet).sign_ecdsa(&DerivationPath::master(), sighash), + ); + match recovered { + Ok((_, key)) => assert_eq!(key == expected, header == genuine, "header {header}"), + Err(error) => assert!( + header != genuine && error.contains("recover"), + "header {header}: {error}" + ), + } + } +} + +// --- threading ------------------------------------------------------------- + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn builders_never_enter_a_tokio_runtime(protocol_version: ProtocolVersion) { + let version = version(protocol_version); + let wallet = Wallet::new(); + assert!( + tokio::runtime::Handle::try_current().is_err(), + "the test runs without a runtime" + ); + let client = mock_client(); + let sdk = offline_sdk(&client, version); + let sender = identity_with_keys( + owner(), + &[ + ( + 1, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + pubkey(&HIGH_SK), + ), + ( + 2, + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, + pubkey(&ENCRYPTION_SK), + ), + ], + ); + let recipient_pk = PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_byte_array(&[0x88u8; 32]).unwrap(), + ); + let recipient = identity_with_keys( + Identifier::from([0x99u8; 32]), + &[( + 3, + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + recipient_pk.serialize(), + )], + ); + let contact = ContactRequestInput { + to_user_id: recipient.id().to_buffer(), + sender_key_index: 2, + recipient_key_index: 3, + recipient_pubkey: recipient_pk.serialize(), + account_reference: 0, + compact_xpub: CompactXpub { + parent_fingerprint: [0; 4], + chain_code: [1; 32], + public_key: pubkey(&[0x5bu8; 32]), + }, + shared_secret: [7u8; 32], + account_label: String::new(), + }; + builders::build_dpns_preorder( + version, + owner().to_buffer(), + 1, + "alice", + &[1u8; 32], + [2u8; 32], + &high_key(), + &wallet, + ) + .unwrap(); + builders::build_dpns_domain( + version, + owner().to_buffer(), + 2, + "alice", + &[1u8; 32], + [2u8; 32], + &high_key(), + &wallet, + ) + .unwrap(); + builders::build_profile( + version, + owner().to_buffer(), + 3, + &ffi::Profile::default(), + &ProfileInput { + display_name: "a".into(), + ..Default::default() + }, + [2u8; 32], + &high_key(), + &wallet, + ) + .unwrap(); + builders::build_contact_request( + &sdk, + version, + &ffi_identity(&sender), + &ffi_identity(&recipient), + 4, + &contact, + &high_key(), + &wallet, + ) + .unwrap(); + builders::build_identity_create( + version, + &proof_input(&instant_proof()), + &new_keys(), + &wallet, + ) + .unwrap(); + assert!( + !*wallet.tokio_runtime_entered.lock().unwrap(), + "a builder called the signer from inside a tokio runtime" + ); + assert_eq!(wallet.requests().len(), 4 + 4); +} + +// --- first-byte vector ----------------------------------------------------- + +/// Writes `test_data/state_transition_first_byte.json`: the first byte of +/// a serialized `StateTransition` is bincode's variant index of the outer +/// enum, which Core's `WalletSigner` checks against the operation kind +/// before it signs. Regenerated from real transitions, never hand-edited. +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn first_byte_vectors(protocol_version: ProtocolVersion) { + let version = version(protocol_version); + let wallet = Wallet::new(); + let batch = builders::build_dpns_preorder( + version, + owner().to_buffer(), + 1, + "alice", + &[1u8; 32], + [2u8; 32], + &high_key(), + &wallet, + ) + .unwrap(); + let identity_create = builders::build_identity_create( + version, + &proof_input(&instant_proof()), + &new_keys(), + &wallet, + ) + .unwrap(); + // Pinned from the latest version's transitions; the variant indexes are + // the same at every version the suite runs. + let vectors = serde_json::json!({ + "description": "First byte of a serialized StateTransition: bincode's variant index of the StateTransition enum (declaration order in rs-dpp state_transition/mod.rs), not StateTransitionType. Core's WalletSigner asserts it matches the SigningOperation kind before signing.", + "protocol_version": version.protocol_version, + "batch": { "first_byte": batch.bytes[0], "example_hex": hex::encode(&batch.bytes[..8]) }, + "identity_create": { "first_byte": identity_create.bytes[0], "example_hex": hex::encode(&identity_create.bytes[..8]) }, + }); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("test_data/state_transition_first_byte.json"); + if protocol_version == LATEST_VERSION { + let rendered = serde_json::to_string_pretty(&vectors).unwrap() + "\n"; + if std::env::var_os("UPDATE_TEST_VECTORS").is_some() { + std::fs::write(&path, &rendered).unwrap(); + } + assert_eq!( + std::fs::read_to_string(&path).unwrap_or_default(), + rendered, + "the checked-in vector file is stale: rerun with UPDATE_TEST_VECTORS=1" + ); + } + let pinned: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(pinned["batch"]["first_byte"], batch.bytes[0]); + assert_eq!( + pinned["identity_create"]["first_byte"], + identity_create.bytes[0] + ); + // Pinned here as well, so a regeneration that changes them fails loudly. + assert_eq!( + batch.bytes[0], 2, + "Batch is the third StateTransition variant" + ); + assert_eq!( + identity_create.bytes[0], 3, + "IdentityCreate is the fourth StateTransition variant" + ); +} diff --git a/packages/rs-platform-cxx/tests/common/mod.rs b/packages/rs-platform-cxx/tests/common/mod.rs new file mode 100644 index 00000000000..7d299f849c7 --- /dev/null +++ b/packages/rs-platform-cxx/tests/common/mod.rs @@ -0,0 +1,927 @@ +// 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. + +//! Shared test setup. +//! +//! `fixture` is a small Platform state built in a real Drive instance and +//! proved the way an evonode proves it: GroveDB proofs from the state, a +//! Tenderdash `StateId` / `CanonicalVote` signed with a BLS quorum key. The +//! replay suite hands those bytes to the client through `dash-sdk`'s mock +//! transport, so the SDK runs the GroveDB replay and the quorum signature +//! check against the key the test pushed exactly as it does against a live +//! node; only the socket is mocked. +//! +//! The fixture is generated once per process for each protocol version the +//! suites run at (see [`Fixture::get`]); one quorum signs them all. +//! `mock_client` wires a `Client` to that transport. Every test installs its +//! own expectation, so the SDK instances are throwaway. + +#![allow(dead_code)] + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use dash_platform_cxx::client::Client; +use dash_platform_cxx::ffi::{Config, Proxy, QuorumKey, StatusKind}; +use dash_sdk::dapi_client::mock::MockResult; +use dash_sdk::dapi_client::transport::{TransportError, TransportRequest}; +use dash_sdk::dapi_client::{DapiClientError, DumpData, ExecutionError, ExecutionResponse}; +use dash_sdk::dpp::block::block_info::BlockInfo; +use dash_sdk::dpp::bls_signatures::{Bls12381G2Impl, SecretKey as BlsSecretKey, SignatureSchemes}; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dash_sdk::dpp::data_contract::DataContract; +use dash_sdk::dpp::document::Document; +use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; +use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; +use dash_sdk::dpp::identity::identity_public_key::methods::hash::IdentityPublicKeyHashMethodsV0; +use dash_sdk::dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::{platform_value, Identifier, Value}; +use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use dash_sdk::dpp::version::{PlatformVersion, ProtocolVersion, LATEST_VERSION}; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dash_sdk::platform::proto::{self, Proof, ResponseMetadata}; +use dash_sdk::platform::types::identity::{IdentityRequest, IdentityResponse}; +use dash_sdk::platform::{Query, QuerySettings}; +use dash_sdk::sdk::min_protocol_version; +use dash_sdk::{RequestSettings, Sdk}; +use dpp::dashcore::secp256k1::rand::rngs::StdRng; +use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; +use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use drive::drive::Drive; +use drive::query::vote_poll_vote_state_query::{ + ContestedDocumentVotePollDriveQuery, ContestedDocumentVotePollDriveQueryResultType, +}; +use drive::query::DriveDocumentQuery; +use drive::util::object_size_info::{ + DataContractOwnedResolvedInfo, DocumentAndContractInfo, DocumentInfo, OwnedDocumentInfo, +}; +use drive::util::storage_flags::StorageFlags; +use drive::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use tenderdash_abci::proto::types::{CanonicalVote, SignedMsgType, StateId}; +use tenderdash_abci::signatures::{Hashable, Signable}; + +/// The protocol version testnet and mainnet run: the SDK's floor for them, +/// which is what a live network serves until it upgrades. Every suite runs +/// at this version and at [`LATEST_VERSION`]. +pub const DEPLOYED_VERSION: ProtocolVersion = min_protocol_version(Network::Testnet); +/// The Platform LLMQ type of the fixture network. +pub const PLATFORM_LLMQ_TYPE: u8 = 106; +/// The Tenderdash chain id the fixture quorum signs for. +pub const CHAIN_ID: &str = "dash-testnet-51"; +/// The Platform height the fixture state is proved at. +pub const HEIGHT: u64 = 123_456; +/// The core-chain-locked height the fixture state is proved at. +pub const CORE_CHAIN_LOCKED_HEIGHT: u32 = 2_000_000; +/// The DPNS label the fixture identity owns. +pub const OWNED_LABEL: &str = "Alice"; +/// A DPNS label under contest between the fixture identities. +pub const CONTESTED_LABEL: &str = "quantum"; +/// A DPNS label nothing in the fixture uses. +pub const ABSENT_LABEL: &str = "nobody"; +/// The nonce the fixture identity has used against the DPNS contract. +pub const DPNS_NONCE: u64 = 3; +/// The DashPay v2 `corePaymentAddress` on the fixture profile (P2PKH type +/// byte and a hash160). +pub const CORE_PAYMENT_ADDRESS: [u8; 21] = [0x1a; 21]; +/// Names the third identity owns: one more than a page. +pub const PAGED_NAME_COUNT: usize = dash_platform_cxx::ops::PAGE_SIZE as usize + 1; + +/// The labels of the third identity's names, none of them contested (the +/// `2` keeps them out of the contested pattern). +pub fn paged_label(i: usize) -> String { + format!("pg2-{i:03}") +} + +pub fn config() -> Config { + Config { + network: 1, + tenderdash_chain_id: CHAIN_ID.to_string(), + platform_llmq_type: PLATFORM_LLMQ_TYPE, + proxy: Proxy { + kind: 0, + address: String::new(), + isolate: false, + }, + } +} + +/// A DPNS `domain` document as `register_dpns_name` builds it, with the +/// system fields Drive would have filled in. +fn domain_document( + contract: &DataContract, + owner: Identifier, + label: &str, + rng: &mut StdRng, + version: &PlatformVersion, +) -> Document { + let document_type = contract.document_type_for_name("domain").expect("domain"); + let data = platform_value!({ + "parentDomainName": "dash", + "normalizedParentDomainName": "dash", + "label": label, + "normalizedLabel": dash_platform_cxx::helpers::normalize_label(label), + "preorderSalt": Value::Bytes32(rng.gen()), + "records": { "identity": owner }, + "subdomainRules": { "allowSubdomains": false }, + "$createdAt": 1_700_000_000_000u64, + "$updatedAt": 1_700_000_000_000u64, + "$transferredAt": 1_700_000_000_000u64, + }); + document_type + .create_document_from_data( + data, + owner, + HEIGHT, + CORE_CHAIN_LOCKED_HEIGHT, + rng.gen(), + version, + ) + .expect("domain document") +} + +fn insert_document( + drive: &Drive, + contract: &DataContract, + document_type: &str, + document: &Document, + version: &PlatformVersion, +) { + let document_type = contract + .document_type_for_name(document_type) + .expect("document type"); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentInfo::DocumentRefInfo(( + document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: None, + }, + contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + version, + None, + ) + .expect("insert document"); +} + +/// The test quorum: one key signs the fixture at every protocol version, so +/// a client trusts it whichever fixture it replays. +pub struct Quorum { + secret: BlsSecretKey, + /// The quorum hash in proof byte order. + pub hash: [u8; 32], + pub pubkey: [u8; 48], +} + +impl Quorum { + /// The quorum key in the embedder's internal `uint256` byte order, the + /// order `set_quorum_keys` takes. + pub fn core_order_key(&self) -> QuorumKey { + let mut hash = self.hash; + hash.reverse(); + QuorumKey { + hash, + pubkey: self.pubkey, + } + } +} + +pub fn quorum() -> &'static Quorum { + static QUORUM: OnceLock = OnceLock::new(); + QUORUM.get_or_init(|| { + let secret = BlsSecretKey::::from_hash(b"dash-platform-cxx fixture quorum"); + let pubkey = secret + .public_key() + .to_bytes() + .try_into() + .expect("48-byte key"); + Quorum { + secret, + hash: std::array::from_fn(|i| (i as u8).wrapping_mul(7)), + pubkey, + } + }) +} + +/// The fixture state at one protocol version: two identities, one DPNS +/// name, one contest, one DashPay profile and contact request, one +/// identity-contract nonce. +pub struct Fixture { + pub version: &'static PlatformVersion, + drive: Drive, + dpns: DataContract, + dashpay: DataContract, + /// The identity that owns `OWNED_LABEL`, the profile and the contact + /// request, with keys 0 AUTH/MASTER, 1 AUTH/HIGH, 2 ENC/MEDIUM, + /// 3 DEC/MEDIUM (the Core key set). + pub alice: Identity, + /// The counterparty of the contact request and the other contender. + pub bob: Identity, + /// Owns [`PAGED_NAME_COUNT`] names and nothing else. + pub carol: Identity, + /// The unique key hash of Alice's key 0. + pub alice_key0_hash: [u8; 20], + /// The `corePaymentAddress` on Alice's profile: [`CORE_PAYMENT_ADDRESS`] + /// where the DashPay contract has the field (v2, protocol version 14), + /// empty before. + pub core_payment_address: Vec, +} + +fn identity_with_core_keys(seed: u64, version: &PlatformVersion) -> Identity { + let mut rng = StdRng::seed_from_u64(seed); + let dashpay = SystemDataContract::Dashpay.id(); + let spec = [ + (0u32, Purpose::AUTHENTICATION, SecurityLevel::MASTER, None), + (1, Purpose::AUTHENTICATION, SecurityLevel::HIGH, None), + ( + 2, + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, + Some(ContractBounds::SingleContractDocumentType { + id: dashpay, + document_type_name: "contactRequest".to_string(), + }), + ), + ( + 3, + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + Some(ContractBounds::SingleContractDocumentType { + id: dashpay, + document_type_name: "contactRequest".to_string(), + }), + ), + ]; + let keys: BTreeMap = spec + .into_iter() + .map(|(id, purpose, level, bounds)| { + let (key, _) = IdentityPublicKey::random_key_with_known_attributes( + id, + &mut rng, + purpose, + level, + KeyType::ECDSA_SECP256K1, + bounds, + version, + ) + .expect("key"); + (id, key) + }) + .collect(); + let mut identity = + Identity::new_with_id_and_keys(Identifier::from(rng.gen::<[u8; 32]>()), keys, version) + .expect("identity"); + identity.set_balance(1_000_000_000); + identity +} + +impl Fixture { + fn build(version: &'static PlatformVersion) -> Self { + let drive = setup_drive_with_initial_state_structure(Some(version)); + let dpns = load_system_data_contract(SystemDataContract::DPNS, version).expect("dpns"); + let dashpay = + load_system_data_contract(SystemDataContract::Dashpay, version).expect("dashpay"); + for contract in [&dpns, &dashpay] { + drive + .apply_contract( + contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + version, + ) + .expect("apply contract"); + } + + let alice = identity_with_core_keys(1, version); + let bob = identity_with_core_keys(2, version); + let carol = identity_with_core_keys(3, version); + for identity in [&alice, &bob, &carol] { + drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .expect("insert identity"); + } + drive + .merge_identity_contract_nonce( + alice.id().to_buffer(), + dpns.id().to_buffer(), + DPNS_NONCE, + &BlockInfo::default(), + true, + None, + &mut vec![], + version, + ) + .expect("nonce"); + + let mut rng = StdRng::seed_from_u64(7); + let owned = domain_document(&dpns, alice.id(), OWNED_LABEL, &mut rng, version); + insert_document(&drive, &dpns, "domain", &owned, version); + for i in 0..PAGED_NAME_COUNT { + let name = domain_document(&dpns, carol.id(), &paged_label(i), &mut rng, version); + insert_document(&drive, &dpns, "domain", &name, version); + } + + let vote_poll = ContestedDocumentResourceVotePollWithContractInfo { + contract: DataContractOwnedResolvedInfo::OwnedDataContract(dpns.clone()), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text(CONTESTED_LABEL.to_string()), + ], + }; + for (contender, first) in [(&alice, true), (&bob, false)] { + let document = + domain_document(&dpns, contender.id(), CONTESTED_LABEL, &mut rng, version); + drive + .add_contested_document( + OwnedDocumentInfo { + document_info: DocumentInfo::DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(contender.id().to_buffer()), + }, + vote_poll.clone(), + false, + first.then(|| { + dash_sdk::dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo::new( + BlockInfo::default(), + version, + ) + .expect("stored info") + }), + &BlockInfo::default(), + true, + None, + version, + ) + .expect("contested document"); + } + for (voter, choice, strength) in [ + ( + [0x11u8; 32], + ResourceVoteChoice::TowardsIdentity(alice.id()), + 1, + ), + ( + [0x12u8; 32], + ResourceVoteChoice::TowardsIdentity(alice.id()), + 4, + ), + ( + [0x13u8; 32], + ResourceVoteChoice::TowardsIdentity(bob.id()), + 1, + ), + ([0x14u8; 32], ResourceVoteChoice::Abstain, 1), + ([0x15u8; 32], ResourceVoteChoice::Lock, 4), + ] { + drive + .register_contested_resource_identity_vote( + voter, + strength, + vote_poll.clone(), + choice, + None, + &BlockInfo::default(), + None, + version, + ) + .expect("vote"); + } + + let profile_type = dashpay.document_type_for_name("profile").expect("profile"); + let core_payment_address = if profile_type.properties().contains_key("corePaymentAddress") { + CORE_PAYMENT_ADDRESS.to_vec() + } else { + Vec::new() + }; + let mut profile_data = platform_value!({ + "displayName": "Alice", + "publicMessage": "hello", + "$createdAt": 1_700_000_000_000u64, + "$updatedAt": 1_700_000_000_000u64, + }); + if !core_payment_address.is_empty() { + profile_data + .set_value( + "corePaymentAddress", + Value::Bytes(core_payment_address.clone()), + ) + .expect("payment address"); + } + let profile = profile_type + .create_document_from_data( + profile_data, + alice.id(), + HEIGHT, + CORE_CHAIN_LOCKED_HEIGHT, + rng.gen(), + version, + ) + .expect("profile"); + insert_document(&drive, &dashpay, "profile", &profile, version); + let contact_type = dashpay + .document_type_for_name("contactRequest") + .expect("contactRequest"); + let contact = contact_type + .create_document_from_data( + platform_value!({ + "toUserId": bob.id(), + "encryptedPublicKey": Value::Bytes(vec![0x42u8; 96]), + "senderKeyIndex": 2u32, + "recipientKeyIndex": 3u32, + "accountReference": 0x1000_0005u32, + "$createdAt": 1_700_000_000_000u64, + "$createdAtCoreBlockHeight": CORE_CHAIN_LOCKED_HEIGHT, + }), + alice.id(), + HEIGHT, + CORE_CHAIN_LOCKED_HEIGHT, + rng.gen(), + version, + ) + .expect("contact request"); + insert_document(&drive, &dashpay, "contactRequest", &contact, version); + + let alice_key0_hash = alice.public_keys()[&0].public_key_hash().expect("key hash"); + Fixture { + version, + drive, + dpns, + dashpay, + alice, + bob, + carol, + alice_key0_hash, + core_payment_address, + } + } + + /// The process-wide fixture at `protocol_version`, built on first use. + pub fn get(protocol_version: ProtocolVersion) -> &'static Fixture { + static FIXTURES: [OnceLock; LATEST_VERSION as usize + 1] = + [const { OnceLock::new() }; LATEST_VERSION as usize + 1]; + let version = PlatformVersion::get(protocol_version).expect("known protocol version"); + FIXTURES[protocol_version as usize].get_or_init(|| Fixture::build(version)) + } + + pub fn dpns(&self) -> &DataContract { + &self.dpns + } + + pub fn dashpay(&self) -> &DataContract { + &self.dashpay + } + + pub fn metadata(&self) -> ResponseMetadata { + ResponseMetadata { + height: HEIGHT, + core_chain_locked_height: CORE_CHAIN_LOCKED_HEIGHT, + epoch: 0, + time_ms: now_ms(), + protocol_version: self.version.protocol_version, + chain_id: CHAIN_ID.to_string(), + } + } + + fn root_hash(&self) -> [u8; 32] { + self.drive + .grove + .root_hash(None, &self.version.drive.grove_version) + .unwrap() + .expect("root hash") + } + + /// Signs the fixture state for `metadata` the way Tenderdash's + /// Platform quorum does, so the proof verifies only against these + /// metadata values and this quorum key. + pub fn proof(&self, grovedb_proof: Vec, metadata: &ResponseMetadata) -> Proof { + let state_id = StateId { + app_version: u64::from(metadata.protocol_version), + core_chain_locked_height: metadata.core_chain_locked_height, + time: metadata.time_ms, + app_hash: self.root_hash().to_vec(), + height: metadata.height, + }; + let round = 0; + let state_id_hash = state_id + .calculate_msg_hash(&metadata.chain_id, metadata.height as i64, round) + .expect("state id hash"); + let block_id_hash = vec![0x33u8; 32]; + let vote = CanonicalVote { + r#type: SignedMsgType::Precommit.into(), + block_id: block_id_hash.clone(), + chain_id: metadata.chain_id.clone(), + height: metadata.height as i64, + round: i64::from(round), + state_id: state_id_hash, + }; + let sign_digest = vote + .calculate_sign_hash( + &metadata.chain_id, + PLATFORM_LLMQ_TYPE, + &quorum().hash, + metadata.height as i64, + round, + ) + .expect("sign digest"); + let signature = quorum() + .secret + .sign(SignatureSchemes::Basic, &sign_digest) + .expect("sign"); + Proof { + grovedb_proof, + quorum_hash: quorum().hash.to_vec(), + signature: signature.as_raw_value().to_compressed().to_vec(), + round: round as u32, + block_id_hash, + quorum_type: u32::from(PLATFORM_LLMQ_TYPE), + } + } + + pub fn prove_identity(&self, id: Identifier) -> Vec { + self.drive + .prove_full_identity(id.to_buffer(), None, &self.version.drive) + .expect("identity proof") + } + + pub fn prove_identity_by_key_hash(&self, hash: [u8; 20]) -> Vec { + self.drive + .prove_full_identity_by_unique_public_key_hash(hash, None, self.version) + .expect("identity by key hash proof") + } + + pub fn prove_identity_contract_nonce(&self, id: Identifier, contract: Identifier) -> Vec { + self.drive + .prove_identity_contract_nonce( + id.to_buffer(), + contract.to_buffer(), + None, + &self.version.drive, + ) + .expect("nonce proof") + } + + /// Proves the documents a `DocumentQuery` selects; the query must be the + /// one the shell builds, so the SDK verifies the proof against it. + pub fn prove_documents(&self, query: &dash_sdk::platform::DocumentQuery) -> Vec { + let drive_query = DriveDocumentQuery::try_from(query).expect("drive query"); + drive_query + .execute_with_proof(&self.drive, None, None, self.version) + .expect("documents proof") + .0 + } + + pub fn prove_contested_vote_state( + &self, + query: ContestedDocumentVotePollDriveQuery, + ) -> Vec { + query + .execute_with_proof(&self.drive, None, None, self.version) + .expect("vote state proof") + .0 + } + + /// The contested-name query the shell issues for `normalized_label`. + pub fn contested_query(&self, normalized_label: &str) -> ContestedDocumentVotePollDriveQuery { + ContestedDocumentVotePollDriveQuery { + vote_poll: dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll { + contract_id: self.dpns.id(), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text(normalized_label.to_string()), + ], + }, + result_type: ContestedDocumentVotePollDriveQueryResultType::VoteTally, + allow_include_locked_and_abstaining_vote_tally: true, + start_at: None, + limit: Some(100), + offset: None, + } + } +} + +pub fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_millis() as u64 +} + +/// A client with the test quorum's key pushed and no ChainLock anchor. +pub fn bare_client() -> Client { + let client = Client::new(&config()).expect("client"); + client + .provider() + .set_quorum_keys(&[quorum().core_order_key()]); + client +} + +/// [`bare_client`] with a ChainLock anchor at `chainlock_height`. +pub fn mock_client_at(chainlock_height: u32) -> Client { + let client = bare_client(); + client + .provider() + .set_local_core_chain_locked_height(chainlock_height); + client +} + +/// A client wired to `dash-sdk`'s mock transport with the fixture's +/// quorum key and a fresh ChainLock anchor at the proved core height. +pub fn mock_client() -> Client { + mock_client_at(CORE_CHAIN_LOCKED_HEIGHT) +} + +/// A mock-transport SDK at `version`, with no expectations: enough for the +/// builders, which only read the compiled-in contracts through the client's +/// provider. +pub fn offline_sdk(client: &Client, version: &'static PlatformVersion) -> Sdk { + client + .mock_sdk_builder() + .with_initial_version(version) + .build() + .expect("mock sdk") +} + +/// Installs the fixture's identity proof for Alice under `metadata`, after +/// `tamper` had its way with the proof. +pub fn install_identity( + fixture: &Fixture, + client: &Client, + metadata: ResponseMetadata, + tamper: impl FnOnce(&mut Proof), +) { + let mut proof = fixture.proof(fixture.prove_identity(fixture.alice.id()), &metadata); + tamper(&mut proof); + install_ok( + fixture, + client, + &identity_request(fixture.alice.id()), + identity_response(proof, metadata), + ); +} + +/// Asserts a status kind, printing the message on a mismatch. +#[track_caller] +pub fn assert_kind(status: &dash_platform_cxx::ffi::Status, kind: StatusKind) { + assert_eq!(status.kind, kind, "{}", status.message); +} + +/// Installs a mock SDK that answers `request` with a successful `response` +/// on `client`, seeded at the fixture's protocol version: the state of a +/// network SDK after its first verified response. +pub fn install_ok(fixture: &Fixture, client: &Client, request: &R, response: R::Response) +where + R: TransportRequest, + R::Response: Clone, +{ + install_seeded( + client, + request, + Ok(execution_response(response)), + Some(fixture.version), + ); +} + +/// [`install_ok`] with the SDK at the per-network protocol floor it seeds +/// itself with before any response has been verified. +pub fn install_at_floor(client: &Client, request: &R, response: R::Response) +where + R: TransportRequest, + R::Response: Clone, +{ + install_seeded(client, request, Ok(execution_response(response)), None); +} + +/// [`install_ok`] with the node refusing `request` with the gRPC `status`. +pub fn install_refusal( + fixture: &Fixture, + client: &Client, + request: &R, + status: dash_sdk::dapi_grpc::tonic::Status, +) where + R: TransportRequest, + R::Response: Clone, +{ + let error = ExecutionError { + inner: DapiClientError::Transport(TransportError::Grpc(status)), + retries: 0, + address: None, + }; + install_seeded(client, request, Err(error), Some(fixture.version)); +} + +/// A successful mock-transport answer. +pub fn execution_response(inner: T) -> ExecutionResponse { + ExecutionResponse { + inner, + retries: 0, + address: "https://127.0.0.1:1".parse().expect("address"), + } +} + +/// The expectation travels through a dump directory because that is the +/// only way to seed the mock transport from outside `dash-sdk`. +fn install_seeded( + client: &Client, + request: &R, + response: MockResult, + initial_version: Option<&'static PlatformVersion>, +) where + R: TransportRequest, + R::Response: Clone, +{ + // Tests run in parallel; a wall-clock name could hand one test + // another's expectation. + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "dash-platform-cxx-mock-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).expect("dump dir"); + let dump = DumpData::new(request, &response); + dump.save(&dir.join(dump.filename().expect("dump file name"))) + .expect("save expectation"); + let mut builder = client.mock_sdk_builder().with_dump_dir(&dir); + if let Some(version) = initial_version { + builder = builder.with_initial_version(version); + } + let sdk: Sdk = builder.build().expect("mock sdk"); + let _ = std::fs::remove_dir_all(&dir); + client.set_sdk(sdk); +} + +/// `Identity::fetch` sends its request through the SDK's `IdentityRequest` +/// wrapper, so the mock keys on the wrapper, not the proto message. +pub fn identity_request(id: Identifier) -> IdentityRequest { + IdentityRequest::GetIdentity(proto::GetIdentityRequest { + version: Some(proto::get_identity_request::Version::V0( + proto::get_identity_request::GetIdentityRequestV0 { + id: id.to_vec(), + prove: true, + }, + )), + }) +} + +pub fn identity_response(proof: Proof, metadata: ResponseMetadata) -> IdentityResponse { + IdentityResponse::GetIdentity(identity_proto_response( + proto::get_identity_response::get_identity_response_v0::Result::Proof(proof), + metadata, + )) +} + +pub fn identity_proto_response( + result: proto::get_identity_response::get_identity_response_v0::Result, + metadata: ResponseMetadata, +) -> proto::GetIdentityResponse { + proto::GetIdentityResponse { + version: Some(proto::get_identity_response::Version::V0( + proto::get_identity_response::GetIdentityResponseV0 { + metadata: Some(metadata), + result: Some(result), + }, + )), + } +} + +pub fn identity_by_key_hash_request(hash: [u8; 20]) -> IdentityRequest { + IdentityRequest::GetIdentityByPublicKeyHash(proto::GetIdentityByPublicKeyHashRequest { + version: Some(proto::get_identity_by_public_key_hash_request::Version::V0( + proto::get_identity_by_public_key_hash_request::GetIdentityByPublicKeyHashRequestV0 { + public_key_hash: hash.to_vec(), + prove: true, + }, + )), + }) +} + +pub fn identity_by_key_hash_response(proof: Proof, metadata: ResponseMetadata) -> IdentityResponse { + IdentityResponse::GetIdentityByPublicKeyHash(proto::GetIdentityByPublicKeyHashResponse { + version: Some(proto::get_identity_by_public_key_hash_response::Version::V0( + proto::get_identity_by_public_key_hash_response::GetIdentityByPublicKeyHashResponseV0 { + metadata: Some(metadata), + result: Some( + proto::get_identity_by_public_key_hash_response::get_identity_by_public_key_hash_response_v0::Result::Proof(proof), + ), + }, + )), + }) +} + +pub fn nonce_request( + id: Identifier, + contract: Identifier, +) -> proto::GetIdentityContractNonceRequest { + proto::GetIdentityContractNonceRequest { + version: Some(proto::get_identity_contract_nonce_request::Version::V0( + proto::get_identity_contract_nonce_request::GetIdentityContractNonceRequestV0 { + identity_id: id.to_vec(), + contract_id: contract.to_vec(), + prove: true, + }, + )), + } +} + +pub fn nonce_response( + proof: Proof, + metadata: ResponseMetadata, +) -> proto::GetIdentityContractNonceResponse { + proto::GetIdentityContractNonceResponse { + version: Some(proto::get_identity_contract_nonce_response::Version::V0( + proto::get_identity_contract_nonce_response::GetIdentityContractNonceResponseV0 { + metadata: Some(metadata), + result: Some( + proto::get_identity_contract_nonce_response::get_identity_contract_nonce_response_v0::Result::Proof(proof), + ), + }, + )), + } +} + +pub fn documents_response(proof: Proof, metadata: ResponseMetadata) -> proto::GetDocumentsResponse { + proto::GetDocumentsResponse { + version: Some(proto::get_documents_response::Version::V0( + proto::get_documents_response::GetDocumentsResponseV0 { + metadata: Some(metadata), + result: Some( + proto::get_documents_response::get_documents_response_v0::Result::Proof(proof), + ), + }, + )), + } +} + +pub fn contested_response( + proof: Proof, + metadata: ResponseMetadata, +) -> proto::GetContestedResourceVoteStateResponse { + proto::GetContestedResourceVoteStateResponse { + version: Some(proto::get_contested_resource_vote_state_response::Version::V0( + proto::get_contested_resource_vote_state_response::GetContestedResourceVoteStateResponseV0 { + metadata: Some(metadata), + result: Some( + proto::get_contested_resource_vote_state_response::get_contested_resource_vote_state_response_v0::Result::Proof(proof), + ), + }, + )), + } +} + +/// The encoder settings of an SDK at the fixture's protocol version, with +/// proofs on; the same `Query` impls the shell's SDK encodes with. +fn query_settings(fixture: &Fixture) -> QuerySettings<'static> { + static REQUEST_SETTINGS: RequestSettings = RequestSettings::default(); + QuerySettings { + request_settings: &REQUEST_SETTINGS, + protocol_version: fixture.version, + prove: true, + } +} + +/// The wire request the shell sends for a `DocumentQuery`. +pub fn documents_request( + fixture: &Fixture, + query: &dash_sdk::platform::DocumentQuery, +) -> proto::GetDocumentsRequest { + query + .query(&query_settings(fixture)) + .expect("documents request") +} + +/// The wire request the shell sends for a contested-name vote state query. +pub fn contested_request( + fixture: &Fixture, + query: &ContestedDocumentVotePollDriveQuery, +) -> proto::GetContestedResourceVoteStateRequest { + query + .query(&query_settings(fixture)) + .expect("contested request") +} diff --git a/packages/rs-platform-cxx/tests/replay.rs b/packages/rs-platform-cxx/tests/replay.rs new file mode 100644 index 00000000000..e9b387db4e4 --- /dev/null +++ b/packages/rs-platform-cxx/tests/replay.rs @@ -0,0 +1,1192 @@ +// 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. + +//! The proved reads and the broadcast, replayed through the same client +//! code an embedder runs, against `dash-sdk`'s mock transport fed with +//! proofs generated from a real Drive state (`common::Fixture`). The SDK's +//! own `FromProof` path replays the GroveDB proof and checks the Tenderdash +//! BLS quorum signature against the key the test pushed through the +//! client; only the socket is mocked. Every test runs against the fixture +//! at the protocol version testnet and mainnet run and at this build's +//! latest. + +mod common; + +use common::{ + assert_kind, bare_client, contested_request, contested_response, documents_request, + documents_response, execution_response, identity_by_key_hash_request, + identity_by_key_hash_response, identity_proto_response, identity_request, install_at_floor, + install_identity, install_ok, install_refusal, mock_client, mock_client_at, nonce_request, + nonce_response, now_ms, offline_sdk, paged_label, quorum, Fixture, ABSENT_LABEL, CHAIN_ID, + CONTESTED_LABEL, CORE_CHAIN_LOCKED_HEIGHT, DEPLOYED_VERSION, DPNS_NONCE, HEIGHT, OWNED_LABEL, + PAGED_NAME_COUNT, PLATFORM_LLMQ_TYPE, +}; +use dash_platform_cxx::client::{Client, HEIGHT_TOLERANCE}; +use dash_platform_cxx::ffi::{StatusKind, WinnerKind}; +use dash_platform_cxx::ops; +use dash_platform_cxx::provider::MAX_CORE_CHAINLOCK_LAG; +use dash_sdk::dapi_client::mock::MockDapiClient; +use dash_sdk::dapi_client::transport::TransportError; +use dash_sdk::dapi_client::{DapiClientError, ExecutionError}; +use dash_sdk::dapi_grpc::tonic::metadata::{MetadataMap, MetadataValue}; +use dash_sdk::dapi_grpc::tonic::{Code, Status}; +use dash_sdk::dpp::consensus::basic::identity::IdentityAssetLockProofLockedTransactionMismatchError; +use dash_sdk::dpp::consensus::basic::BasicError; +use dash_sdk::dpp::consensus::codes::ErrorWithCode; +use dash_sdk::dpp::consensus::ConsensusError; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::Txid; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_nonce::IDENTITY_NONCE_VALUE_FILTER; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::serialization::PlatformSerializableWithPlatformVersion; +use dash_sdk::dpp::version::v14::PROTOCOL_VERSION_14; +use dash_sdk::dpp::version::{ProtocolVersion, LATEST_VERSION}; +use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; +use dash_sdk::platform::proto::{self, ResponseMetadata}; +use dash_sdk::platform::types::identity::IdentityResponse; +use dash_sdk::platform::DocumentQuery; +use dash_sdk::query_types::IdentityContractNonceFetcher; +use test_case::test_matrix; + +/// A client that answers `get_identity(alice)` with the fixture proof under +/// `metadata`. +fn identity_client(fixture: &Fixture, metadata: ResponseMetadata) -> Client { + let client = mock_client(); + install_identity(fixture, &client, metadata, |_| {}); + client +} + +fn alice_id(fixture: &Fixture) -> [u8; 32] { + fixture.alice.id().to_buffer() +} + +// --- identities -------------------------------------------------------------- + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn identity_verifies_end_to_end_with_its_keys(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = identity_client(fixture, fixture.metadata()); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_kind(&result.status, StatusKind::Ok); + assert_eq!(result.value.id, alice_id(fixture)); + assert_eq!(result.value.balance, fixture.alice.balance()); + assert_eq!(result.value.keys.len(), 4); + let key2 = &result.value.keys[2]; + assert_eq!((key2.id, key2.purpose, key2.security_level), (2, 1, 3)); + assert_eq!( + key2.bounds.kind, + dash_platform_cxx::ffi::BoundsKind::SingleContractDocumentType + ); + assert_eq!(key2.bounds.contract_id, fixture.dashpay().id().to_buffer()); + assert_eq!(key2.bounds.document_type, "contactRequest"); + assert_eq!(result.meta.height, HEIGHT); + assert_eq!( + result.meta.core_chain_locked_height, + CORE_CHAIN_LOCKED_HEIGHT + ); + assert_eq!(result.meta.chain_id, CHAIN_ID); + assert_eq!( + result.meta.protocol_version, + fixture.version.protocol_version + ); + assert_eq!(client.last_seen_height(), HEIGHT); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn identity_by_public_key_hash_verifies(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let metadata = fixture.metadata(); + let proof = fixture.proof( + fixture.prove_identity_by_key_hash(fixture.alice_key0_hash), + &metadata, + ); + install_ok( + fixture, + &client, + &identity_by_key_hash_request(fixture.alice_key0_hash), + identity_by_key_hash_response(proof, metadata), + ); + let result = ops::get_identity_by_pubkey_hash(&client, fixture.alice_key0_hash); + assert_kind(&result.status, StatusKind::Ok); + assert_eq!(result.value.id, alice_id(fixture)); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn unknown_public_key_hash_is_proven_absent(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let metadata = fixture.metadata(); + let hash = [0x77u8; 20]; + let proof = fixture.proof(fixture.prove_identity_by_key_hash(hash), &metadata); + install_ok( + fixture, + &client, + &identity_by_key_hash_request(hash), + identity_by_key_hash_response(proof, metadata), + ); + let result = ops::get_identity_by_pubkey_hash(&client, hash); + assert_kind(&result.status, StatusKind::ProvenAbsent); + assert_eq!( + result.meta.height, HEIGHT, + "absence still carries verified metadata" + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn identity_contract_nonce_is_masked(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let metadata = fixture.metadata(); + let contract = fixture.dpns().id(); + let proof = fixture.proof( + fixture.prove_identity_contract_nonce(fixture.alice.id(), contract), + &metadata, + ); + install_ok( + fixture, + &client, + &nonce_request(fixture.alice.id(), contract), + nonce_response(proof, metadata), + ); + // The stored nonce carries missing-revision bits above the 40-bit + // value: the SDK's own reads see them, the shell masks them. + let raw = client + .run(move |sdk| async move { + let query = (fixture.alice.id(), contract); + dash_sdk::platform::Fetch::fetch_with_metadata(&sdk, query, None) + .await + .map(|(fetcher, _): (Option, _)| fetcher.map(|f| f.0)) + }) + .expect("direct fetch") + .expect("fetch") + .expect("present"); + assert_eq!(raw & IDENTITY_NONCE_VALUE_FILTER, DPNS_NONCE); + assert_ne!( + raw, DPNS_NONCE, + "the stored nonce carries missing-revision bits" + ); + let result = ops::get_identity_contract_nonce(&client, alice_id(fixture), contract.to_buffer()); + assert_kind(&result.status, StatusKind::Ok); + assert_eq!(result.value, DPNS_NONCE); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn nonce_of_an_unused_contract_is_proven_absent(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let metadata = fixture.metadata(); + let contract = fixture.dashpay().id(); + let proof = fixture.proof( + fixture.prove_identity_contract_nonce(fixture.alice.id(), contract), + &metadata, + ); + install_ok( + fixture, + &client, + &nonce_request(fixture.alice.id(), contract), + nonce_response(proof, metadata), + ); + let result = ops::get_identity_contract_nonce(&client, alice_id(fixture), contract.to_buffer()); + assert_kind(&result.status, StatusKind::ProvenAbsent); + assert_eq!( + result.value, 0, + "the next nonce is 1, as after a value of 0" + ); + assert_eq!(result.meta.height, HEIGHT); +} + +// --- freshness and trust gates ----------------------------------------------- + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn proofless_response_is_rejected(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let response = IdentityResponse::GetIdentity(identity_proto_response( + proto::get_identity_response::get_identity_response_v0::Result::Identity(vec![]), + fixture.metadata(), + )); + install_ok( + fixture, + &client, + &identity_request(fixture.alice.id()), + response, + ); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_kind(&result.status, StatusKind::Rejected); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn tampered_grovedb_proof_is_rejected(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let metadata = fixture.metadata(); + let mut grovedb_proof = fixture.prove_identity(fixture.alice.id()); + let middle = grovedb_proof.len() / 2; + grovedb_proof[middle] ^= 0x01; + let proof = fixture.proof(grovedb_proof, &metadata); + install_ok( + fixture, + &client, + &identity_request(fixture.alice.id()), + common::identity_response(proof, metadata), + ); + assert_kind( + &ops::get_identity(&client, alice_id(fixture)).status, + StatusKind::Rejected, + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn tampered_quorum_signature_is_rejected(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + install_identity(fixture, &client, fixture.metadata(), |proof| { + proof.signature[10] ^= 0x01 + }); + assert_kind( + &ops::get_identity(&client, alice_id(fixture)).status, + StatusKind::Rejected, + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn unknown_quorum_is_rejected(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = identity_client(fixture, fixture.metadata()); + client.provider().set_quorum_keys(&[]); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_eq!(result.status.kind, StatusKind::Rejected); + assert!(result + .status + .message + .contains("no locally known Platform quorum")); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn quorum_hash_in_proof_order_does_not_match(protocol_version: ProtocolVersion) { + // The embedder pushes its internal byte order; a caller that already + // reversed the hash pushes the wrong key. + let fixture = Fixture::get(protocol_version); + let client = identity_client(fixture, fixture.metadata()); + client + .provider() + .set_quorum_keys(&[dash_platform_cxx::ffi::QuorumKey { + hash: quorum().hash, + pubkey: quorum().pubkey, + }]); + assert_eq!( + ops::get_identity(&client, alice_id(fixture)).status.kind, + StatusKind::Rejected + ); + client + .provider() + .set_quorum_keys(&[quorum().core_order_key()]); + assert_eq!( + ops::get_identity(&client, alice_id(fixture)).status.kind, + StatusKind::Ok + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn non_platform_quorum_type_is_rejected(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + install_identity(fixture, &client, fixture.metadata(), |proof| { + proof.quorum_type = u32::from(PLATFORM_LLMQ_TYPE) + 1 + }); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_eq!(result.status.kind, StatusKind::Rejected); + assert!(result.status.message.contains("quorum type")); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn foreign_chain_id_is_a_mismatch_and_moves_no_shell_state(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let mut metadata = fixture.metadata(); + metadata.chain_id = "dash-mainnet".to_string(); + let client = identity_client(fixture, metadata); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_kind(&result.status, StatusKind::ChainIdMismatch); + assert_eq!( + client.last_seen_height(), + 0, + "the shell watermark is untouched" + ); + // The same signed proof under the configured chain id verifies. + let client = identity_client(fixture, fixture.metadata()); + assert_kind( + &ops::get_identity(&client, alice_id(fixture)).status, + StatusKind::Ok, + ); + assert_eq!(client.last_seen_height(), HEIGHT); + assert!(client.verified_platform_version().is_ok()); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn foreign_chain_id_still_ratchets_the_sdk_version(protocol_version: ProtocolVersion) { + // The SDK ratchets its protocol version inside proof verification, + // before the shell's chain-id check runs: a validly signed proof from + // another chain of a higher known version moves the SDK version (from + // the floor to 14; at 13 there is nothing to move). The shell keeps its + // own state untouched (above) and P1 (`with_expected_chain_id`) moves + // the check upstream; this pins the current order so a change is + // noticed. + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let mut metadata = fixture.metadata(); + metadata.chain_id = "dash-mainnet".to_string(); + let proof = fixture.proof(fixture.prove_identity(fixture.alice.id()), &metadata); + install_at_floor( + &client, + &identity_request(fixture.alice.id()), + common::identity_response(proof, metadata), + ); + assert_eq!(client.platform_version().protocol_version, DEPLOYED_VERSION); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_kind(&result.status, StatusKind::ChainIdMismatch); + assert_eq!( + client.platform_version().protocol_version, + fixture.version.protocol_version, + "the SDK ratcheted on the foreign chain's verified metadata" + ); + assert_eq!(client.last_seen_height(), 0, "the shell moved nothing"); + assert!( + client.verified_platform_version().is_err(), + "no transition can be built before a read the shell accepted" + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn no_local_anchor_is_unavailable_and_dispatches_nothing(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = bare_client(); + // No expectation is installed: the read must not reach the transport. + client.set_sdk(offline_sdk(&client, fixture.version)); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_kind(&result.status, StatusKind::Unavailable); + assert!(result.status.message.contains("anchor")); + client + .provider() + .set_local_core_chain_locked_height(CORE_CHAIN_LOCKED_HEIGHT); + install_identity(fixture, &client, fixture.metadata(), |_| {}); + assert_kind( + &ops::get_identity(&client, alice_id(fixture)).status, + StatusKind::Ok, + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn version_ratchets_up_from_the_network_floor_on_a_verified_response( + protocol_version: ProtocolVersion, +) { + // A network SDK seeds itself at the per-network floor (PV13 on + // testnet) and learns the real version from the first verified + // response, the fixture's. Until then no transition can be built, not + // even when the network turns out to run the floor itself. + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let metadata = fixture.metadata(); + let proof = fixture.proof(fixture.prove_identity(fixture.alice.id()), &metadata); + install_at_floor( + &client, + &identity_request(fixture.alice.id()), + common::identity_response(proof, metadata), + ); + assert_eq!(client.platform_version().protocol_version, DEPLOYED_VERSION); + let error = client.verified_platform_version().unwrap_err(); + assert!(error.contains("no verified read yet"), "{error}"); + assert_kind( + &ops::get_identity(&client, alice_id(fixture)).status, + StatusKind::Ok, + ); + assert_eq!( + client.platform_version().protocol_version, + fixture.version.protocol_version, + "the SDK follows the verified response" + ); + assert_eq!( + client + .verified_platform_version() + .expect("verified") + .protocol_version, + fixture.version.protocol_version + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn chainlock_lag_floor_and_no_ceiling(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = identity_client(fixture, fixture.metadata()); + client + .provider() + .set_local_core_chain_locked_height(CORE_CHAIN_LOCKED_HEIGHT + MAX_CORE_CHAINLOCK_LAG); + assert_kind( + &ops::get_identity(&client, alice_id(fixture)).status, + StatusKind::Ok, + ); + let client = identity_client(fixture, fixture.metadata()); + client + .provider() + .set_local_core_chain_locked_height(CORE_CHAIN_LOCKED_HEIGHT + MAX_CORE_CHAINLOCK_LAG + 1); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_kind(&result.status, StatusKind::Rejected); + assert!(result.status.message.contains("stale")); + // A proof one ChainLock ahead of the local node is honest. + let client_behind = mock_client_at(CORE_CHAIN_LOCKED_HEIGHT - 1); + install_identity(fixture, &client_behind, fixture.metadata(), |_| {}); + assert_kind( + &ops::get_identity(&client_behind, alice_id(fixture)).status, + StatusKind::Ok, + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn platform_height_watermark_tolerates_three_blocks(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let serve = |height: u64| { + let mut metadata = fixture.metadata(); + metadata.height = height; + install_identity(fixture, &client, metadata, |_| {}); + ops::get_identity(&client, alice_id(fixture)).status + }; + assert_kind(&serve(HEIGHT), StatusKind::Ok); + assert_kind(&serve(HEIGHT - 2), StatusKind::Ok); + assert_kind(&serve(HEIGHT - HEIGHT_TOLERANCE), StatusKind::Ok); + let stale = serve(HEIGHT - HEIGHT_TOLERANCE - 1); + assert_kind(&stale, StatusKind::Rejected); + assert!(stale.message.contains("trails")); + assert_kind(&serve(HEIGHT - HEIGHT_TOLERANCE - 2), StatusKind::Rejected); + assert_eq!(client.last_seen_height(), HEIGHT); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn a_stale_response_records_no_protocol_version(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let serve = |height: u64, protocol_version: u32| { + let mut metadata = fixture.metadata(); + metadata.height = height; + metadata.protocol_version = protocol_version; + install_identity(fixture, &client, metadata, |_| {}); + ops::get_identity(&client, alice_id(fixture)).status + }; + assert_kind( + &serve(HEIGHT, fixture.version.protocol_version), + StatusKind::Ok, + ); + let verified = client + .verified_platform_version() + .expect("verified") + .protocol_version; + // A validly signed but stale response claiming a newer version is + // rejected before its version is recorded: the builders stay open. + assert_kind( + &serve(HEIGHT - HEIGHT_TOLERANCE - 1, LATEST_VERSION + 1), + StatusKind::Rejected, + ); + assert_eq!( + client + .verified_platform_version() + .expect("still verified") + .protocol_version, + verified + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn signed_time_outside_the_window_is_rejected(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let mut metadata = fixture.metadata(); + metadata.time_ms = now_ms() - 11 * 60 * 1000; + let client = identity_client(fixture, metadata); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_kind(&result.status, StatusKind::Rejected); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn unsupported_protocol_version_is_signalled_with_the_value(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let mut metadata = fixture.metadata(); + metadata.protocol_version = LATEST_VERSION + 1; + let client = identity_client(fixture, metadata); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_kind(&result.status, StatusKind::UnsupportedProtocolVersion); + assert_eq!( + result.value.id, + alice_id(fixture), + "the verified value is still returned" + ); + assert_eq!(result.meta.protocol_version, LATEST_VERSION + 1); + assert_eq!( + client.sdk().expect("sdk").version().protocol_version, + fixture.version.protocol_version, + "the SDK never ratchets to an unknown version" + ); + // The shell records it: no transition is built for a network this + // build does not know, even after a read at a known version. + let error = client.verified_platform_version().unwrap_err(); + assert!(error.contains("no transition can be built"), "{error}"); + install_identity(fixture, &client, fixture.metadata(), |_| {}); + assert_kind( + &ops::get_identity(&client, alice_id(fixture)).status, + StatusKind::Ok, + ); + assert!(client.verified_platform_version().is_err()); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn absence_under_an_unsupported_version_is_still_proven_absent(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let mut metadata = fixture.metadata(); + metadata.protocol_version = LATEST_VERSION + 1; + let hash = [0x77u8; 20]; + let proof = fixture.proof(fixture.prove_identity_by_key_hash(hash), &metadata); + install_ok( + fixture, + &client, + &identity_by_key_hash_request(hash), + identity_by_key_hash_response(proof, metadata), + ); + let result = ops::get_identity_by_pubkey_hash(&client, hash); + assert_kind(&result.status, StatusKind::ProvenAbsent); + assert_eq!(result.meta.protocol_version, LATEST_VERSION + 1); + // The version is recorded all the same: nothing gets built. + assert!(client.verified_platform_version().is_err()); +} + +// --- documents --------------------------------------------------------------- + +fn dpns_domain_query(fixture: &Fixture) -> DocumentQuery { + DocumentQuery::new(fixture.dpns().clone(), "domain").expect("query") +} + +fn dash_tld_query(fixture: &Fixture) -> DocumentQuery { + dpns_domain_query(fixture).with_where(WhereClause { + field: "normalizedParentDomainName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("dash".to_string()), + }) +} + +fn install_documents(fixture: &Fixture, client: &Client, query: &DocumentQuery) { + let metadata = fixture.metadata(); + let proof = fixture.proof(fixture.prove_documents(query), &metadata); + install_ok( + fixture, + client, + &documents_request(fixture, query), + documents_response(proof, metadata), + ); +} + +/// The continuation of `query` after the document `cursor`. +fn continuation(mut query: DocumentQuery, cursor: [u8; 32]) -> DocumentQuery { + query.start = Some( + proto::get_documents_request::get_documents_request_v0::Start::StartAfter(cursor.to_vec()), + ); + query +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn resolve_name_finds_the_owned_name(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let normalized = dash_platform_cxx::helpers::normalize_label(OWNED_LABEL); + let query = dash_tld_query(fixture) + .with_where(WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(normalized.clone()), + }) + .with_limit(1); + install_documents(fixture, &client, &query); + let result = ops::resolve_name(&client, OWNED_LABEL); + assert_kind(&result.status, StatusKind::Ok); + assert_eq!(result.value.label, OWNED_LABEL); + assert_eq!(result.value.normalized_label, normalized); + assert_eq!(result.value.parent, "dash"); + assert_eq!(result.value.identity, alice_id(fixture)); + assert_eq!(result.value.owner, alice_id(fixture)); + assert_ne!(result.value.document_id, [0u8; 32]); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn resolve_name_proves_absence(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let query = dash_tld_query(fixture) + .with_where(WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(dash_platform_cxx::helpers::normalize_label(ABSENT_LABEL)), + }) + .with_limit(1); + install_documents(fixture, &client, &query); + let result = ops::resolve_name(&client, ABSENT_LABEL); + assert_kind(&result.status, StatusKind::ProvenAbsent); + assert_eq!(result.meta.height, HEIGHT); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn search_names_by_prefix_clamps_the_limit(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let query = dash_tld_query(fixture) + .with_where(WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::StartsWith, + value: Value::Text("a1".to_string()), + }) + .with_order_by(OrderClause { + field: "normalizedLabel".to_string(), + ascending: true, + }) + .with_limit(ops::PAGE_SIZE); + install_documents(fixture, &client, &query); + // 500 is clamped to the page size, so the shell issues the query above. + let result = ops::search_names(&client, "A1", 500, None); + assert_kind(&result.status, StatusKind::Ok); + assert_eq!(result.items.len(), 1); + assert_eq!(result.items[0].label, OWNED_LABEL); + assert!(!result.page.has_more); +} + +#[test] +fn search_names_refuses_what_the_network_would_before_dispatch() { + // No SDK at all: a dispatched read would come back `Unavailable`. + let client = mock_client(); + for prefix in ["", &"a".repeat(64)] { + let result = ops::search_names(&client, prefix, 10, None); + assert_kind(&result.status, StatusKind::Internal); + assert!( + result.status.message.contains("prefix"), + "{}", + result.status.message + ); + } + assert_kind( + &ops::search_names(&client, &"a".repeat(63), 10, None).status, + StatusKind::Unavailable, + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn search_names_pages_against_its_limit_with_a_cursor(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let search = |limit: u32| { + dash_tld_query(fixture) + .with_where(WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::StartsWith, + value: Value::Text("pg2".to_string()), + }) + .with_order_by(OrderClause { + field: "normalizedLabel".to_string(), + ascending: true, + }) + .with_limit(limit) + }; + // A page that fills a limit below the page size has more. + install_documents(fixture, &client, &search(1)); + let one = ops::search_names(&client, "pg2", 1, None); + assert_kind(&one.status, StatusKind::Ok); + assert_eq!(one.items.len(), 1); + assert_eq!(one.items[0].label, paged_label(0)); + assert!(one.page.has_more); + // The next single row follows the cursor. + install_documents( + fixture, + &client, + &continuation(search(1), one.page.next_start_after), + ); + let two = ops::search_names(&client, "pg2", 1, Some(one.page.next_start_after)); + assert_kind(&two.status, StatusKind::Ok); + assert_eq!(two.items.len(), 1); + assert_eq!(two.items[0].label, paged_label(1)); + assert!(two.page.has_more); + + install_documents(fixture, &client, &search(ops::PAGE_SIZE)); + let first = ops::search_names(&client, "pg2", ops::PAGE_SIZE, None); + assert_kind(&first.status, StatusKind::Ok); + assert_eq!(first.items.len(), ops::PAGE_SIZE as usize); + assert!(first.page.has_more); + let cursor = first.page.next_start_after; + assert_eq!(cursor, first.items.last().expect("an item").document_id); + + install_documents( + fixture, + &client, + &continuation(search(ops::PAGE_SIZE), cursor), + ); + let rest = ops::search_names(&client, "pg2", ops::PAGE_SIZE, Some(cursor)); + assert_kind(&rest.status, StatusKind::Ok); + assert_eq!(rest.items.len(), PAGED_NAME_COUNT - ops::PAGE_SIZE as usize); + assert!(!rest.page.has_more); + let mut labels: Vec = first + .items + .iter() + .chain(rest.items.iter()) + .map(|name| name.label.clone()) + .collect(); + labels.sort(); + labels.dedup(); + assert_eq!(labels.len(), PAGED_NAME_COUNT); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn a_read_the_node_refuses_is_rejected_not_unavailable(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let query = dash_tld_query(fixture) + .with_where(WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::StartsWith, + value: Value::Text("a1".to_string()), + }) + .with_order_by(OrderClause { + field: "normalizedLabel".to_string(), + ascending: true, + }) + .with_limit(ops::PAGE_SIZE); + let request = documents_request(fixture, &query); + // Drive refuses the query (`Status::invalid_argument`): the node + // answered, and retrying elsewhere would not change its mind. + install_refusal( + fixture, + &client, + &request, + Status::invalid_argument("bad query"), + ); + let result = ops::search_names(&client, "A1", ops::PAGE_SIZE, None); + assert_kind(&result.status, StatusKind::Rejected); + // tonic's answer to a response above the decoding bound. + install_refusal( + fixture, + &client, + &request, + Status::out_of_range("too large"), + ); + assert_kind( + &ops::search_names(&client, "A1", ops::PAGE_SIZE, None).status, + StatusKind::Rejected, + ); + // The node did not answer: an outage. + install_refusal(fixture, &client, &request, Status::unavailable("down")); + assert_kind( + &ops::search_names(&client, "A1", ops::PAGE_SIZE, None).status, + StatusKind::Unavailable, + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn names_of_identity_pages_with_a_cursor(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let query = dpns_domain_query(fixture) + .with_where(WhereClause { + field: "records.identity".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(alice_id(fixture)), + }) + .with_limit(ops::PAGE_SIZE); + install_documents(fixture, &client, &query); + let result = ops::names_of_identity(&client, alice_id(fixture), None); + assert_kind(&result.status, StatusKind::Ok); + // Only the owned name: Alice's contender document for the contested + // name is not in the identity index until the contest resolves. + assert_eq!(result.items.len(), 1); + assert_eq!(result.items[0].label, OWNED_LABEL); + assert_eq!( + result.page.next_start_after, + result.items.last().expect("an item").document_id + ); + assert!(!result.page.has_more, "a short page is the last one"); + // A continuation is a distinct query with the cursor. + install_documents( + fixture, + &client, + &continuation(query, result.page.next_start_after), + ); + let rest = ops::names_of_identity( + &client, + alice_id(fixture), + Some(result.page.next_start_after), + ); + if protocol_version < PROTOCOL_VERSION_14 { + // Protocol version 13 cannot continue this index (see below). + assert_kind(&rest.status, StatusKind::Unavailable); + return; + } + assert_kind(&rest.status, StatusKind::Ok); + assert!(rest.items.is_empty()); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn a_full_page_reports_more_and_the_cursor_continues(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let carol = fixture.carol.id().to_buffer(); + let query = dpns_domain_query(fixture) + .with_where(WhereClause { + field: "records.identity".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(carol), + }) + .with_limit(ops::PAGE_SIZE); + install_documents(fixture, &client, &query); + let first = ops::names_of_identity(&client, carol, None); + assert_kind(&first.status, StatusKind::Ok); + assert_eq!(first.items.len(), ops::PAGE_SIZE as usize); + assert!(first.page.has_more, "a full page may have more"); + let cursor = first.page.next_start_after; + assert_eq!(cursor, first.items.last().expect("an item").document_id); + + install_documents(fixture, &client, &continuation(query, cursor)); + let rest = ops::names_of_identity(&client, carol, Some(cursor)); + if protocol_version < PROTOCOL_VERSION_14 { + // Protocol version 13's lowering of a cursor on this one-property, + // non-unique index skips every remaining name under the identity: + // Drive proves an empty last page (fixed in 14), which the shell + // refuses rather than report the list as complete. + assert_kind(&rest.status, StatusKind::Unavailable); + assert!(rest.items.is_empty()); + assert_eq!(rest.meta.protocol_version, protocol_version); + return; + } + assert_kind(&rest.status, StatusKind::Ok); + assert!(!rest.page.has_more); + assert_eq!(rest.items.len(), PAGED_NAME_COUNT - ops::PAGE_SIZE as usize); + // The two pages partition the names: no overlap, nothing skipped. + let mut labels: Vec = first + .items + .iter() + .chain(rest.items.iter()) + .map(|name| name.label.clone()) + .collect(); + labels.sort(); + labels.dedup(); + assert_eq!(labels.len(), PAGED_NAME_COUNT); + assert!((0..PAGED_NAME_COUNT).all(|i| labels.contains(&paged_label(i)))); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn profile_verifies_and_absence_is_proven(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let profile_query = |owner: [u8; 32]| { + DocumentQuery::new(fixture.dashpay().clone(), "profile") + .expect("query") + .with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(owner), + }) + .with_limit(1) + }; + install_documents(fixture, &client, &profile_query(alice_id(fixture))); + let result = ops::get_profile(&client, alice_id(fixture)); + assert_kind(&result.status, StatusKind::Ok); + assert_eq!(result.value.display_name, "Alice"); + assert_eq!(result.value.public_message, "hello"); + assert_eq!(result.value.revision, 1); + assert_eq!(result.value.owner, alice_id(fixture)); + assert!(result.value.avatar_url.is_empty()); + assert_eq!( + result.value.core_payment_address, + fixture.core_payment_address + ); + assert!(result.value.platform_payment_address.is_empty()); + assert_ne!(result.value.created_at, 0); + + let bob = fixture.bob.id().to_buffer(); + install_documents(fixture, &client, &profile_query(bob)); + let absent = ops::get_profile(&client, bob); + assert_kind(&absent.status, StatusKind::ProvenAbsent); + assert_eq!( + absent.meta.height, HEIGHT, + "absence still carries verified metadata" + ); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn profile_reads_at_the_network_floor_before_the_first_ratchet(protocol_version: ProtocolVersion) { + // A network SDK's first proved read runs at the per-network protocol + // floor. At 14 the fixture profile is stored under DashPay v2, which + // the floor's contract cannot decode; at 13 under v1. The shell + // queries with the latest compiled-in contract, which decodes both. + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let query = DocumentQuery::new(fixture.dashpay().clone(), "profile") + .expect("query") + .with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(alice_id(fixture)), + }) + .with_limit(1); + let metadata = fixture.metadata(); + let proof = fixture.proof(fixture.prove_documents(&query), &metadata); + install_at_floor( + &client, + &documents_request(fixture, &query), + documents_response(proof, metadata), + ); + assert_eq!(client.platform_version().protocol_version, DEPLOYED_VERSION); + let result = ops::get_profile(&client, alice_id(fixture)); + assert_kind(&result.status, StatusKind::Ok); + assert_eq!(result.value.display_name, "Alice"); +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn contact_requests_to_me_and_from_me(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let bob = fixture.bob.id().to_buffer(); + let contact_query = |field: &str, id: [u8; 32], since: Option| { + let mut query = DocumentQuery::new(fixture.dashpay().clone(), "contactRequest") + .expect("query") + .with_where(WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(id), + }); + if let Some(since) = since { + query = query.with_where(WhereClause { + field: "$createdAt".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::U64(since), + }); + } + query + .with_order_by(OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }) + .with_limit(ops::PAGE_SIZE) + }; + install_documents(fixture, &client, &contact_query("toUserId", bob, None)); + let to_bob = ops::get_contact_requests(&client, bob, true, 0, None); + assert_kind(&to_bob.status, StatusKind::Ok); + assert_eq!(to_bob.items.len(), 1); + let request = &to_bob.items[0]; + assert_eq!(request.owner, alice_id(fixture)); + assert_eq!(request.to_user_id, bob); + assert_eq!(request.encrypted_public_key.len(), 96); + assert_eq!( + (request.sender_key_index, request.recipient_key_index), + (2, 3) + ); + assert_eq!(request.account_reference, 0x1000_0005); + assert!(request.encrypted_account_label.is_empty()); + assert_eq!(request.core_height_created_at, CORE_CHAIN_LOCKED_HEIGHT); + assert!(!to_bob.page.has_more); + + install_documents( + fixture, + &client, + &contact_query("$ownerId", alice_id(fixture), Some(1_700_000_000_000)), + ); + let from_alice_later = + ops::get_contact_requests(&client, alice_id(fixture), false, 1_700_000_000_000, None); + assert_kind(&from_alice_later.status, StatusKind::Ok); + assert!( + from_alice_later.items.is_empty(), + "nothing after the creation time" + ); +} + +// --- contested names --------------------------------------------------------- + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn contested_vote_state_tallies_and_absence(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let install_contest = |client: &Client, label: &str| { + let query = fixture.contested_query(&dash_platform_cxx::helpers::normalize_label(label)); + let metadata = fixture.metadata(); + let proof = fixture.proof(fixture.prove_contested_vote_state(query.clone()), &metadata); + install_ok( + fixture, + client, + &contested_request(fixture, &query), + contested_response(proof, metadata), + ); + }; + install_contest(&client, CONTESTED_LABEL); + let result = ops::get_contested_vote_state(&client, CONTESTED_LABEL); + assert_kind(&result.status, StatusKind::Ok); + let state = result.value; + assert_eq!(state.winner_kind, WinnerKind::NoWinner); + assert_eq!(state.ends_at, 0); + assert_eq!((state.abstain, state.lock), (1, 4)); + assert_eq!(state.contenders.len(), 2); + let votes = |id: [u8; 32]| { + state + .contenders + .iter() + .find(|contender| contender.identity == id) + .map(|contender| (contender.votes, contender.has_votes)) + }; + assert_eq!(votes(alice_id(fixture)), Some((5, true))); + assert_eq!(votes(fixture.bob.id().to_buffer()), Some((1, true))); + + install_contest(&client, ABSENT_LABEL); + let absent = ops::get_contested_vote_state(&client, ABSENT_LABEL); + assert_kind(&absent.status, StatusKind::ProvenAbsent); +} + +// --- broadcast --------------------------------------------------------------- + +fn broadcast_request(bytes: &[u8]) -> proto::BroadcastStateTransitionRequest { + proto::BroadcastStateTransitionRequest { + state_transition: bytes.to_vec(), + } +} + +fn grpc_failure(status: Status) -> ExecutionError { + ExecutionError { + inner: DapiClientError::Transport(TransportError::Grpc(status)), + retries: 0, + address: None, + } +} + +/// Submits `bytes` through a mock transport answering with `result`, the +/// same executor path `Sdk::execute` takes on a mock SDK. +fn submit_with( + bytes: &[u8], + result: Result<(), ExecutionError>, +) -> dash_platform_cxx::ffi::Status { + let request = broadcast_request(bytes); + let mut transport = MockDapiClient::new(); + let result = result.map(|()| execution_response(proto::BroadcastStateTransitionResponse {})); + transport.expect(&request, &result).expect("expectation"); + futures::executor::block_on(ops::submit(&transport, request)) +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn broadcast_outcomes_are_typed(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let bytes = vec![0x02u8; 64]; + + assert_eq!(submit_with(&bytes, Ok(())).kind, StatusKind::Ok); + assert_eq!( + submit_with( + &bytes, + Err(grpc_failure(Status::new( + Code::AlreadyExists, + "already in mempool" + ))) + ) + .kind, + StatusKind::AlreadyExists + ); + // A definitive non-consensus refusal is a rejection, an outage is not. + assert_eq!( + submit_with( + &bytes, + Err(grpc_failure(Status::new( + Code::InvalidArgument, + "malformed" + ))) + ) + .kind, + StatusKind::Rejected + ); + assert_eq!( + submit_with( + &bytes, + Err(grpc_failure(Status::new(Code::Unavailable, "down"))) + ) + .kind, + StatusKind::Unavailable + ); + + // A consensus error travels as gRPC metadata; the SDK's error + // conversion decodes it and the shell reports its code. (The mock + // transport serializes a gRPC status without its metadata, so this + // outcome is checked on the classifier directly.) + let consensus_error = ConsensusError::BasicError( + BasicError::IdentityAssetLockProofLockedTransactionMismatchError( + IdentityAssetLockProofLockedTransactionMismatchError::new( + Txid::from_byte_array([0; 32]), + Txid::from_byte_array([1; 32]), + ), + ), + ); + let mut metadata = MetadataMap::new(); + metadata.insert_bin( + "dash-serialized-consensus-error-bin", + MetadataValue::from_bytes( + &consensus_error + .serialize_to_bytes_with_platform_version(fixture.version) + .expect("serialize"), + ), + ); + let status = ops::broadcast_status(Err(DapiClientError::Transport(TransportError::Grpc( + Status::with_metadata(Code::InvalidArgument, "consensus", metadata), + )))); + assert_kind(&status, StatusKind::Consensus); + assert_eq!(status.consensus_code, consensus_error.code()); + assert_eq!( + ops::broadcast_status(Err(DapiClientError::NoAvailableAddresses)).kind, + StatusKind::Unavailable + ); + + // Through the client: the size bound, and a transport that never + // answers is an outage, not a success. + let client = mock_client(); + client.set_sdk(offline_sdk(&client, fixture.version)); + assert_eq!( + ops::broadcast(&client, &vec![0u8; 100 * 1024 + 1]) + .status + .kind, + StatusKind::Internal + ); + let unanswered = ops::broadcast(&client, &bytes).status; + assert_ne!(unanswered.kind, StatusKind::Ok); + assert_ne!(unanswered.kind, StatusKind::AlreadyExists); +} + +#[test] +fn broadcast_to_an_unreachable_endpoint_is_unavailable() { + let client = Client::new(&common::config()).expect("client"); + client + .set_endpoints(&["https://127.0.0.1:1".to_string()]) + .expect("endpoint"); + let result = ops::broadcast(&client, &[0x02u8; 64]); + assert_kind(&result.status, StatusKind::Unavailable); + client.shutdown(); +} + +// --- lifecycle --------------------------------------------------------------- + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn reads_without_endpoints_or_after_shutdown_are_unavailable(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_eq!(result.status.kind, StatusKind::Unavailable); + assert!(result.status.message.contains("no evonode endpoints")); + let client = identity_client(fixture, fixture.metadata()); + assert_eq!( + ops::get_identity(&client, alice_id(fixture)).status.kind, + StatusKind::Ok + ); + client.shutdown(); + let result = ops::get_identity(&client, alice_id(fixture)); + assert_eq!(result.status.kind, StatusKind::Unavailable); + assert!( + result.status.message.contains("shut down"), + "{}", + result.status.message + ); + client.shutdown(); +} From 7499032aa746a575355bb5840dd4b2ae1c36f4a7 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 23 Sep 2026 23:36:05 -0500 Subject: [PATCH 3/5] docs(sdk): document dash-platform-cxx and wire it into Rust CI The README states the trust model (pushed quorum keys and ChainLock anchor, chain-id check, shell watermark, protocol-version signal, proven absence, refusal versus outage, panic containment), summarizes the bridge API by group, describes the SOCKS5 proxy passthrough (fixed Config.proxy, no direct fallback, per-connection isolation, proxy failures Unavailable without banning, the 15 s proxied connect budget), and fixes the threading and signing contract (blocking reads on the embedder's worker, builders on the calling thread, SignForKey over the signable preimage with the first-byte variant index, SignAssetLockSighash as the one digest path). CI runs the crate's tests with nextest, links tests/cxx_smoke.cc from the staged headers and archive, and asserts the embedder's trust closure: no trusted-context-provider, reqwest, openssl-sys or native-tls in the dependency tree and no seed-list or unproved entry points in the sources. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/tests-rs-workspace.yml | 26 +++ packages/rs-platform-cxx/README.md | 197 +++++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 packages/rs-platform-cxx/README.md diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 19e21262574..435dbfbc081 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -269,6 +269,31 @@ jobs: done done + # The C++ embedder (Dash Core's platform GUI) links dash-platform-cxx + # as a static archive whose trust anchor must be the embedder's own + # LLMQ store and masternode list: no trusted-provider crate, no HTTP + # client, no OpenSSL, no default seed list may enter its closure or + # its sources. (It wraps the full dash-sdk, networking included, so + # it is deliberately absent from the transport-free cuts above.) + - name: Check the Platform CXX embedder's trust closure + run: | + for banned in rs-sdk-trusted-context-provider reqwest openssl-sys native-tls; do + if cargo tree -p dash-platform-cxx -e normal -i "$banned" 2>/dev/null | grep -q .; then + echo "::error::$banned leaked into dash-platform-cxx's dependency tree" + exit 1 + fi + done + if grep -rnE 'new_mainnet|new_testnet|fetch_unproved|dash_network_seeds' \ + packages/rs-platform-cxx/src packages/rs-platform-cxx/include; then + echo "::error::dash-platform-cxx must build its SDK from the embedder's endpoints only" + exit 1 + fi + + # The C++ embedding surface must link and run from the staged headers + # and archive alone; the Rust tests cannot see a broken header layout. + - name: Link the Platform CXX embedder from C++ + run: packages/rs-platform-cxx/scripts/cxx-smoke.sh + - name: Detect immutable structure changes if: github.event_name == 'pull_request' run: | @@ -456,6 +481,7 @@ jobs: --package rs-sdk-ffi \ --package platform-wallet-ffi \ --package rs-dapi-client \ + --package dash-platform-cxx \ --package platform-serialization \ --package dapi-grpc \ --package json-schema-compatibility-validator \ diff --git a/packages/rs-platform-cxx/README.md b/packages/rs-platform-cxx/README.md new file mode 100644 index 00000000000..131c4b65e4c --- /dev/null +++ b/packages/rs-platform-cxx/README.md @@ -0,0 +1,197 @@ +# dash-platform-cxx + +Dash Platform for C++ embedders, as a thin [`cxx`](https://cxx.rs) shell over +`dash-sdk`. The embedder supplies what only it knows: the evonode endpoints +from its masternode list, the Platform quorum keys from its LLMQ store, its +best ChainLock height, and signatures from its wallet. `dash-sdk` supplies +everything else: query construction, transport, retries with address +banning, proof verification, the signed-time window, protocol-version +tracking, contact-request minting. Dash Core's platform GUI is the first +consumer. + +## Trust model + +Every response byte comes from an untrusted node; nothing in the closure +fetches from a trusted third-party service (no `rs-sdk-trusted-context-provider`, +no HTTP client, no default seed list; CI asserts it). Trust rests on inputs +the embedder pushes: + +- **Quorum keys** (`set_quorum_keys`, the full active set, hashes in the + embedder's internal byte order). The provider (`src/provider.rs`) hands the + verifier a key only if the proof names the network's Platform LLMQ type and + a pushed hash; anything else fails signature verification. +- **ChainLock anchor** (`set_chainlock_height`, monotonic). Until the first + push no proved read is dispatched (`Unavailable`). A proof whose signed + core-chain-locked height trails the anchor by more than 288 blocks is + refused as stale; there is no ceiling, since a node one ChainLock ahead of + the embedder is honest. +- **Chain id** (`Config.tenderdash_chain_id`). After the SDK verified the + quorum signature and its signed-time window, the shell (`src/ops.rs`) + compares the signed `chain_id` (`ChainIdMismatch`), then applies its own + monotonic Platform-height watermark (tolerance 3 blocks, `Rejected`), then + records the verified protocol version the builders use, then flags a + `protocol_version` above what this build knows + (`UnsupportedProtocolVersion`, the value is still returned). The SDK's own + height watermark is off and the shell keys its watermark and its verified + version after the chain-id check, so a validly signed proof from another + chain moves nothing the shell decides on (the SDK's internal version + ratchet, which runs inside verification, may move upward on it; nothing + reads that for building). + +Absence is proven, never inferred: `ProvenAbsent` comes only after the SDK +verified a proof of it, and carries the same verified metadata as a value +(also under an unsupported protocol version, which `meta` then shows). +Every failure is classified by error variant into `Status.kind`, never by +message text: a node that definitively refuses a request (a gRPC code the +SDK would not retry, including a response above the 4 MiB decoding bound) +is `Rejected`; no answer at all is `Unavailable`. Every bridge entry point runs under `catch_unwind`, so a panic +on hostile input is a `rust::Error` or an `Internal` status, never an +abort; the crate refuses to build with `panic = "abort"`. + +## API + +Namespace `platform_ffi`; the full declaration is `src/lib.rs`, the +generated header `dash/platform/ffi.h`. + +- **Lifecycle**: `new_platform_client(Config{network, tenderdash_chain_id, + platform_llmq_type, proxy})`, `set_endpoints(&[String])` (`https://` only; + the host an IP address, or an onion name when a proxy is set, so nothing + is ever resolved locally), `set_quorum_keys`, `set_chainlock_height`, + `shutdown()` (aborts the in-flight request, stops the runtime; idempotent, + also run on drop). + The SDK keeps a pooled connection per evonode it has talked to, and only + dropping the SDK closes them: an empty endpoint set drops it (no socket + stays open while the embedder has disabled networking), a set that + removes endpoints rebuilds it over the updated list (retained entries + keep their ban state), a set that only adds updates it in place. The + quorum keys, the ChainLock anchor, the height watermark and the verified + protocol version belong to the client and survive every rebuild. +- **Proved reads**, one SDK request each, returning `Verified*{status, meta, + value | items + page}`: `get_identity`, `get_identity_by_pubkey_hash`, + `get_identity_contract_nonce` (masked to the 40-bit value; `ProvenAbsent` + = the identity has not used the contract, i.e. 0), `resolve_name`, + `search_names` (prefix of 1 to 63 normalized characters, limit clamped to + 1..=100), `names_of_identity`, `get_profile`, `get_contact_requests` (to + or from an identity, after a time, oldest first), + `get_contested_vote_state`. Paged reads return one page of up to their + limit; `page.has_more` is set on a page that fills it (which may still be + the last one) and `page.next_start_after` is the cursor for the next call + (all zero = from the start). Queries use this build's latest compiled-in + contracts, which decode every older document; the SDK's tracked version + only matters for building. At protocol version 13 Drive answers a + continuation of `names_of_identity` with an empty page, so only its first + 100 names are reachable there and a continuation is `Unavailable` rather + than a last page. +- **Broadcast**: `broadcast(&[u8]) -> BroadcastResult{status}`, advisory and + typed: `Ok`, `AlreadyExists`, `Consensus` with `consensus_code`, `Rejected` + (a definitive non-consensus refusal), `Unavailable` (no answer). Every + write is confirmed by a proved re-query. +- **Builders**, no network, returning `Built{bytes, hash, object_id}`: + `build_identity_create`, `build_dpns_preorder`, `build_dpns_domain` (the + caller supplies and persists the salt), `build_profile` (create, or + replace the `Profile` a `get_profile` returned at its revision + 1: a + replace carries the whole document, so the avatar and payment-address + fields another wallet set are carried over unedited), + `build_contact_request` (minted by `Sdk::create_contact_request` with the + embedder's ECDH secret). A create carries the document id + `dash-sdk`'s `put_to_platform` would derive at the network's version + (from the entropy alone up to protocol version 13, also from the identity + contract nonce from 14), and a property the network's contract does not + have yet (DashPay's payment addresses before 14) is refused before + anything is signed. Transition rules change across protocol + versions, so every builder (and `contested_vote_fund_credits`) needs the + version a verified read has shown the network to run: before the first + read the shell accepted they fail with a `rust::Error`, except on a + devnet, whose floor is the latest version (regtest's is not: one read + first). Once a read has shown a version this build does not know, they + fail too, until the embedder is updated. The version only moves up, on + each accepted read; the nonce read right before a build refreshes it. +- **Pure helpers**: `normalize_label`, `is_valid_username`, + `is_contested_username`, `credits_per_duff`, `system_contract_id`, and the + DIP-15 pieces that need only 32-byte inputs: `dip15_decrypt_xpub`, + `dip15_account_reference_from_mac`, + `dip15_unmask_account_reference_from_mac`, `dip15_select_recipient_key`, + `dip15_receive_keys_acceptable`. The infallible ones answer `false`, `0` + or empty on a (contained) panic, which reads as a refusal. + +`Status.kind` is one of `Ok, ProvenAbsent, AlreadyExists, Consensus, +Unavailable, Rejected, ChainIdMismatch, UnsupportedProtocolVersion, +Internal`; a `Verified*` value is meaningful only under `Ok` and +`UnsupportedProtocolVersion`. + +## Proxy + +`Config.proxy` is the embedder's SOCKS5 proxy, fixed for the client's +lifetime (Dash Core's proxy settings need a restart to change): +`Proxy{kind, address, isolate}` with `kind` 0 for none (`address` empty), +1 for TCP (`address` a numeric `ip:port`, IPv6 in brackets) or 2 for a Unix +socket (`address` its path). With a proxy, every Platform connection goes +through it (`DapiClient::with_proxy` in `rs-dapi-client`), with no fallback +to a direct connection: the evonode's address goes to the proxy +unresolved, and TLS runs end to end with the evonode, validated as without +a proxy. `isolate` sends fresh random SOCKS5 credentials on every +connection, so Tor builds a separate circuit for each (`-proxyrandomize`). +A proxy the shell cannot use (unknown kind, a host name, an empty path) is +a `rust::Error` from `new_platform_client`, so the embedder gets no client +rather than a direct one. + +A failure on the proxy's side (unreachable, no answer to the SOCKS5 +greeting within 5 s, SOCKS protocol or credential error, general failure) +is `Unavailable` and bans no evonode: the SDK does not retry it, since every +endpoint shares the proxy. Tor also answers a general failure when a single +circuit fails, so such a request fails at once and the next one, on a fresh +circuit with `isolate`, may succeed. A proxy's reply about the evonode +itself (unreachable, refused, not allowed by its rules) and a connect +timeout after the proxy answered (Tor holding the reply while it builds a +circuit, for example while it bootstraps) ban that evonode and retry +another, as without a proxy. +The connect budget is 15 s through a proxy instead of 5 s, so one request +can hold the embedder's worker for up to (15 + 20) s × 3 attempts = 105 s. + +## Threading and signing + +- Reads and the broadcast block the calling thread until the SDK request + completes or `shutdown` aborts it; the embedder serializes them on its own + worker. One tokio runtime (`src/runtime.rs`, two worker threads with + 16 MiB stacks for GroveDB proof replay) is owned per client. No single + call issues more than one SDK request. +- Builders run to completion on the calling thread: dpp's async builders are + driven by a local executor, no runtime is entered, and the `WalletSigner` + is only ever invoked on the thread that called the builder (tested). The + embedder's `WalletSigner` (`include/dash/platform/signer.h`) must + nonetheless be callable from any thread (take the wallet's own lock, no + thread-local state); that contract, stated in the header, is what its + `Send + Sync` impls rest on. +- `SignForKey(key_id, signable)` receives the full signable preimage of the + transition, so the wallet hashes it (double SHA256) itself and can check + what it signs; the first byte is the `StateTransition` variant index + (`test_data/state_transition_first_byte.json`: 2 = batch, 3 = identity + create). It answers with a 65-byte compact recoverable ECDSA signature, + exactly `dashcore::signer::sign` over the raw key. + `SignAssetLockSighash(sighash)` is the one digest path: the asset lock's + outpoint key signs the 32-byte double SHA256 the builder computed; the + public key is recovered from the signature (compressed-key header, + 31 + recovery id; anything else is refused), so it is never exported. + Private keys never cross the FFI; the shell never derives keys. + +## Building and testing + +An ordinary workspace member: `cargo build -p dash-platform-cxx --release`. +`build.rs` stages `dash/platform/ffi.h`, `dash/platform/signer.h` and +`rust/cxx.h` under `target//include/`; install that tree and +`libdash_platform_cxx.a`, link with `-lpthread -lm` (`-ldl` on Linux, +`-framework Security -framework CoreFoundation` on macOS for the rustls +trust store). `scripts/cxx-smoke.sh` compiles and runs `tests/cxx_smoke.cc` +against exactly that interface. + +`cargo test -p dash-platform-cxx` runs the unit tests, `tests/replay.rs` +(a real Drive state proved and BLS-signed by a test quorum, replayed through +`dash-sdk`'s mock transport so the SDK runs the full GroveDB replay and +signature check: the freshness matrix, absence, paging, broadcast +classification) and `tests/builders.rs` (every builder byte for byte against +dpp's in-process private-key constructions, the document ids and data Drive +validates, signer refusals, the no-reactor invariant, and +`test_data/state_transition_first_byte.json` against the transitions it +builds; `UPDATE_TEST_VECTORS=1` rewrites that file). Both suites run every +test at protocol version 13 (what testnet and mainnet run) and at this +build's latest, the fixture state generated at each. From 33cfcc4a3b157dc4463af957241059975a2aa64f Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 3 Oct 2026 20:07:35 -0500 Subject: [PATCH 4/5] feat(sdk)!: drop the expected Tenderdash chain id from dash-platform-cxx The shell compared each verified response's signed chain_id with a configured Config.tenderdash_chain_id. That check adds nothing: chain_id is part of the message the Platform quorum signs, so a relabelled id fails verification, and the signing quorum must be one the embedder pushed from its own Core chain, so a response from another network does not verify at all. The stale-node case after a testnet reset is covered by the SDK's signed-time window and the 288-block ChainLock lag floor. This matches the reasoning that closed #4963. Removed from the bridge: Config.tenderdash_chain_id, Meta.chain_id and StatusKind::ChainIdMismatch; UnsupportedProtocolVersion and Internal are renumbered to 6 and 7. Client::tenderdash_chain_id() and the empty-id refusal in Client::new are gone. accept() now runs the height watermark, then records the verified protocol version, then raises the unsupported-version signal. The replay suite replaces the two foreign-chain-id cases with one that relabels the chain id after signing (Rejected, no shell state moved) and one that accepts a proof any pushed Platform quorum signed for another chain id. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/rs-platform-cxx/README.md | 34 +++++----- packages/rs-platform-cxx/src/client.rs | 27 ++------ packages/rs-platform-cxx/src/lib.rs | 12 +--- packages/rs-platform-cxx/src/ops.rs | 24 ++----- packages/rs-platform-cxx/tests/common/mod.rs | 1 - packages/rs-platform-cxx/tests/cxx_smoke.cc | 2 - packages/rs-platform-cxx/tests/replay.rs | 71 +++++++------------- 7 files changed, 57 insertions(+), 114 deletions(-) diff --git a/packages/rs-platform-cxx/README.md b/packages/rs-platform-cxx/README.md index 131c4b65e4c..012d19cc272 100644 --- a/packages/rs-platform-cxx/README.md +++ b/packages/rs-platform-cxx/README.md @@ -25,18 +25,19 @@ the embedder pushes: core-chain-locked height trails the anchor by more than 288 blocks is refused as stale; there is no ceiling, since a node one ChainLock ahead of the embedder is honest. -- **Chain id** (`Config.tenderdash_chain_id`). After the SDK verified the - quorum signature and its signed-time window, the shell (`src/ops.rs`) - compares the signed `chain_id` (`ChainIdMismatch`), then applies its own - monotonic Platform-height watermark (tolerance 3 blocks, `Rejected`), then - records the verified protocol version the builders use, then flags a +- **Post-verification checks**. After the SDK verified the quorum + signature and its signed-time window, the shell (`src/ops.rs`) applies its + own monotonic Platform-height watermark (tolerance 3 blocks, `Rejected`), + then records the verified protocol version the builders use, then flags a `protocol_version` above what this build knows (`UnsupportedProtocolVersion`, the value is still returned). The SDK's own - height watermark is off and the shell keys its watermark and its verified - version after the chain-id check, so a validly signed proof from another - chain moves nothing the shell decides on (the SDK's internal version - ratchet, which runs inside verification, may move upward on it; nothing - reads that for building). + height watermark is off and the shell records its verified version only + after its watermark, so a stale response moves nothing the shell decides + on (the SDK's internal version ratchet, which runs inside verification, + may move upward on it; nothing reads that for building). The signed + Tenderdash `chain_id` is not compared to an expected value: it is part of + the signed message, and the signing quorum must be a Platform quorum of + the embedder's own Core chain. Absence is proven, never inferred: `ProvenAbsent` comes only after the SDK verified a proof of it, and carries the same verified metadata as a value @@ -53,10 +54,10 @@ abort; the crate refuses to build with `panic = "abort"`. Namespace `platform_ffi`; the full declaration is `src/lib.rs`, the generated header `dash/platform/ffi.h`. -- **Lifecycle**: `new_platform_client(Config{network, tenderdash_chain_id, - platform_llmq_type, proxy})`, `set_endpoints(&[String])` (`https://` only; - the host an IP address, or an onion name when a proxy is set, so nothing - is ever resolved locally), `set_quorum_keys`, `set_chainlock_height`, +- **Lifecycle**: `new_platform_client(Config{network, platform_llmq_type, + proxy})`, `set_endpoints(&[String])` (`https://` only; the host an IP + address, or an onion name when a proxy is set, so nothing is ever + resolved locally), `set_quorum_keys`, `set_chainlock_height`, `shutdown()` (aborts the in-flight request, stops the runtime; idempotent, also run on drop). The SDK keeps a pooled connection per evonode it has talked to, and only @@ -115,9 +116,8 @@ generated header `dash/platform/ffi.h`. or empty on a (contained) panic, which reads as a refusal. `Status.kind` is one of `Ok, ProvenAbsent, AlreadyExists, Consensus, -Unavailable, Rejected, ChainIdMismatch, UnsupportedProtocolVersion, -Internal`; a `Verified*` value is meaningful only under `Ok` and -`UnsupportedProtocolVersion`. +Unavailable, Rejected, UnsupportedProtocolVersion, Internal`; a `Verified*` +value is meaningful only under `Ok` and `UnsupportedProtocolVersion`. ## Proxy diff --git a/packages/rs-platform-cxx/src/client.rs b/packages/rs-platform-cxx/src/client.rs index 45c9aae7ed5..4d831654c38 100644 --- a/packages/rs-platform-cxx/src/client.rs +++ b/packages/rs-platform-cxx/src/client.rs @@ -51,7 +51,6 @@ const SHUT_DOWN: &str = "platform client is shut down"; pub struct Client { network: Network, - tenderdash_chain_id: String, /// Fixed for the client's lifetime: the embedder's proxy settings do /// not change without a restart, so no SDK ever runs without it. proxy: Option, @@ -66,7 +65,7 @@ pub struct Client { /// Highest protocol version a response the shell accepted has carried /// (0 = none yet), known to this build or not. Kept apart from the /// SDK's ratchet, which runs inside proof verification before the - /// shell's chain-id check. + /// shell's height watermark. verified_protocol_version: AtomicU32, } @@ -79,12 +78,8 @@ impl Client { 3 => Network::Regtest, other => return Err(format!("unknown network {other}")), }; - if cfg.tenderdash_chain_id.is_empty() { - return Err("the tenderdash chain id must not be empty".to_string()); - } Ok(Client { network, - tenderdash_chain_id: cfg.tenderdash_chain_id.clone(), proxy: proxy(&cfg.proxy)?, provider: Arc::new(LocalContextProvider::new(cfg.platform_llmq_type)), runtime: Runtime::new()?, @@ -99,10 +94,6 @@ impl Client { &self.provider } - pub fn tenderdash_chain_id(&self) -> &str { - &self.tenderdash_chain_id - } - /// Replaces the evonode endpoint set. Only `https` endpoints are /// accepted: the address list takes any scheme and would dial `http` in /// the clear. The host must be an IP address, or an onion name when a @@ -310,9 +301,8 @@ impl Client { /// The SDK's view of the protocol version: the network floor until a /// verified response ratchets it. The SDK ratchets inside proof - /// verification, before the shell's chain-id check, so a validly signed - /// proof from another chain can move it; nothing the shell decides - /// reads it. + /// verification, before the shell's height watermark, so a response the + /// shell then refuses can move it; nothing the shell decides reads it. pub fn platform_version(&self) -> &'static PlatformVersion { match read(&self.sdk).as_ref() { Some(sdk) => sdk.version(), @@ -326,8 +316,8 @@ impl Client { } /// Records the protocol version of a response the shell accepted (after - /// the chain-id check and the height watermark). Monotonic; the signed - /// metadata covers it, so it is trusted as far as the quorum is. + /// the height watermark). Monotonic; the signed metadata covers it, so + /// it is trusted as far as the quorum is. pub fn observe_protocol_version(&self, version: u32) { self.verified_protocol_version .fetch_max(version, Ordering::AcqRel); @@ -452,7 +442,6 @@ mod tests { fn config(proxy: ffi::Proxy) -> ffi::Config { ffi::Config { network: 1, - tenderdash_chain_id: "dash-testnet-51".to_string(), platform_llmq_type: 106, proxy, } @@ -478,11 +467,6 @@ mod tests { ..config(no_proxy()) }) .is_err()); - assert!(Client::new(&ffi::Config { - tenderdash_chain_id: String::new(), - ..config(no_proxy()) - }) - .is_err()); } #[test] @@ -903,7 +887,6 @@ mod tests { assert!(error.contains("no transition can be built"), "{error}"); let devnet = Client::new(&ffi::Config { network: 2, - tenderdash_chain_id: "devnet".to_string(), ..config(no_proxy()) }) .expect("client"); diff --git a/packages/rs-platform-cxx/src/lib.rs b/packages/rs-platform-cxx/src/lib.rs index 13d8c1188b6..dd5d429d89f 100644 --- a/packages/rs-platform-cxx/src/lib.rs +++ b/packages/rs-platform-cxx/src/lib.rs @@ -67,14 +67,12 @@ pub mod ffi { /// (a broadcast without a consensus code, a read it would not serve, /// a response above the size bound). Rejected = 5, - /// A verified response signed for another Tenderdash chain. - ChainIdMismatch = 6, /// A verified response from a protocol version this build does not /// know; the value is returned, writes must stop until an update. - UnsupportedProtocolVersion = 7, + UnsupportedProtocolVersion = 6, /// A bug: bad input from the embedder, a panic, or an SDK error that /// no other kind describes. - Internal = 8, + Internal = 7, } #[derive(Debug, Clone, Default)] @@ -100,13 +98,10 @@ pub mod ffi { /// Network the client serves. `network`: 0 mainnet, 1 testnet, /// 2 devnet, 3 regtest. `platform_llmq_type`: the LLMQ type Platform /// quorums use on this network; a proof signed by any other type is - /// refused. `tenderdash_chain_id`: a verified response carrying another - /// id is a signed response from another chain. `proxy` is fixed for the - /// client's lifetime. + /// refused. `proxy` is fixed for the client's lifetime. #[derive(Debug, Clone)] struct Config { network: u8, - tenderdash_chain_id: String, platform_llmq_type: u8, proxy: Proxy, } @@ -127,7 +122,6 @@ pub mod ffi { core_chain_locked_height: u32, time_ms: u64, protocol_version: u32, - chain_id: String, } /// Where an identity key may be used. diff --git a/packages/rs-platform-cxx/src/ops.rs b/packages/rs-platform-cxx/src/ops.rs index 20d9f6912a8..4fd2c3522d0 100644 --- a/packages/rs-platform-cxx/src/ops.rs +++ b/packages/rs-platform-cxx/src/ops.rs @@ -5,9 +5,8 @@ //! The proved reads and the broadcast, one SDK request each. Every read //! goes through the SDK's `Fetch` / `FetchMany`, so the SDK verifies the //! response against the query it built, then through [`accept`]: the -//! chain-id check, the shell's Platform-height watermark and the -//! unsupported-protocol-version signal, in that order, on metadata the -//! quorum signature already covers. +//! shell's Platform-height watermark and the unsupported-protocol-version +//! signal, in that order, on metadata the quorum signature already covers. //! //! Absence is proven, not inferred: `ProvenAbsent` comes back only after //! the SDK verified a proof of it. Every failure is classified into a @@ -109,7 +108,6 @@ fn meta(metadata: &ResponseMetadata) -> ffi::Meta { core_chain_locked_height: metadata.core_chain_locked_height, time_ms: metadata.time_ms, protocol_version: metadata.protocol_version, - chain_id: metadata.chain_id.clone(), } } @@ -166,21 +164,11 @@ pub fn classify(error: &Error) -> Status { Status::new(kind, error.to_string()) } -/// The shell's post-verification checks, in order: the chain id, then the -/// height watermark (so a foreign chain never moves it), then the verified -/// protocol version the builders use, then the protocol-version signal, -/// which still returns the value. +/// The shell's post-verification checks, in order: the height watermark +/// (so a stale response never moves the rest), then the verified protocol +/// version the builders use, then the protocol-version signal, which still +/// returns the value. fn accept(client: &Client, metadata: &ResponseMetadata) -> Result { - if metadata.chain_id != client.tenderdash_chain_id() { - return Err(Status::new( - StatusKind::ChainIdMismatch, - format!( - "response signed for tenderdash chain {:?}, expected {:?}", - metadata.chain_id, - client.tenderdash_chain_id() - ), - )); - } if !client.observe_height(metadata.height) { return Err(Status::new( StatusKind::Rejected, diff --git a/packages/rs-platform-cxx/tests/common/mod.rs b/packages/rs-platform-cxx/tests/common/mod.rs index 7d299f849c7..8f3ef386359 100644 --- a/packages/rs-platform-cxx/tests/common/mod.rs +++ b/packages/rs-platform-cxx/tests/common/mod.rs @@ -99,7 +99,6 @@ pub fn paged_label(i: usize) -> String { pub fn config() -> Config { Config { network: 1, - tenderdash_chain_id: CHAIN_ID.to_string(), platform_llmq_type: PLATFORM_LLMQ_TYPE, proxy: Proxy { kind: 0, diff --git a/packages/rs-platform-cxx/tests/cxx_smoke.cc b/packages/rs-platform-cxx/tests/cxx_smoke.cc index 27a9f26a5d8..e2cfbaeac9c 100644 --- a/packages/rs-platform-cxx/tests/cxx_smoke.cc +++ b/packages/rs-platform-cxx/tests/cxx_smoke.cc @@ -35,7 +35,6 @@ int main() { platform_ffi::Config cfg; cfg.network = 1; - cfg.tenderdash_chain_id = "dash-testnet-51"; cfg.platform_llmq_type = 106; cfg.proxy.kind = 0; cfg.proxy.isolate = false; @@ -139,7 +138,6 @@ int main() return 1; platform_ffi::Config devnet_cfg; devnet_cfg.network = 2; - devnet_cfg.tenderdash_chain_id = "devnet"; devnet_cfg.platform_llmq_type = 106; rust::Box devnet = platform_ffi::new_platform_client(devnet_cfg); if (devnet->contested_vote_fund_credits() == 0) return fail("contested_vote_fund_credits"); diff --git a/packages/rs-platform-cxx/tests/replay.rs b/packages/rs-platform-cxx/tests/replay.rs index e9b387db4e4..43cf38b4fb8 100644 --- a/packages/rs-platform-cxx/tests/replay.rs +++ b/packages/rs-platform-cxx/tests/replay.rs @@ -18,7 +18,7 @@ use common::{ documents_response, execution_response, identity_by_key_hash_request, identity_by_key_hash_response, identity_proto_response, identity_request, install_at_floor, install_identity, install_ok, install_refusal, mock_client, mock_client_at, nonce_request, - nonce_response, now_ms, offline_sdk, paged_label, quorum, Fixture, ABSENT_LABEL, CHAIN_ID, + nonce_response, now_ms, offline_sdk, paged_label, quorum, Fixture, ABSENT_LABEL, CONTESTED_LABEL, CORE_CHAIN_LOCKED_HEIGHT, DEPLOYED_VERSION, DPNS_NONCE, HEIGHT, OWNED_LABEL, PAGED_NAME_COUNT, PLATFORM_LLMQ_TYPE, }; @@ -87,7 +87,6 @@ fn identity_verifies_end_to_end_with_its_keys(protocol_version: ProtocolVersion) result.meta.core_chain_locked_height, CORE_CHAIN_LOCKED_HEIGHT ); - assert_eq!(result.meta.chain_id, CHAIN_ID); assert_eq!( result.meta.protocol_version, fixture.version.protocol_version @@ -304,60 +303,42 @@ fn non_platform_quorum_type_is_rejected(protocol_version: ProtocolVersion) { } #[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] -fn foreign_chain_id_is_a_mismatch_and_moves_no_shell_state(protocol_version: ProtocolVersion) { +fn relabelled_chain_id_is_rejected_and_moves_no_shell_state(protocol_version: ProtocolVersion) { + // The quorum signature covers the Tenderdash chain id, so metadata + // relabelled after signing fails verification. let fixture = Fixture::get(protocol_version); - let mut metadata = fixture.metadata(); - metadata.chain_id = "dash-mainnet".to_string(); - let client = identity_client(fixture, metadata); - let result = ops::get_identity(&client, alice_id(fixture)); - assert_kind(&result.status, StatusKind::ChainIdMismatch); - assert_eq!( - client.last_seen_height(), - 0, - "the shell watermark is untouched" + let client = mock_client(); + let signed = fixture.metadata(); + let proof = fixture.proof(fixture.prove_identity(fixture.alice.id()), &signed); + let mut relabelled = signed; + relabelled.chain_id = "dash-mainnet".to_string(); + install_ok( + fixture, + &client, + &identity_request(fixture.alice.id()), + common::identity_response(proof, relabelled), ); - // The same signed proof under the configured chain id verifies. - let client = identity_client(fixture, fixture.metadata()); assert_kind( &ops::get_identity(&client, alice_id(fixture)).status, - StatusKind::Ok, + StatusKind::Rejected, ); - assert_eq!(client.last_seen_height(), HEIGHT); - assert!(client.verified_platform_version().is_ok()); + assert_eq!(client.last_seen_height(), 0, "the shell moved nothing"); + assert!(client.verified_platform_version().is_err()); } #[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] -fn foreign_chain_id_still_ratchets_the_sdk_version(protocol_version: ProtocolVersion) { - // The SDK ratchets its protocol version inside proof verification, - // before the shell's chain-id check runs: a validly signed proof from - // another chain of a higher known version moves the SDK version (from - // the floor to 14; at 13 there is nothing to move). The shell keeps its - // own state untouched (above) and P1 (`with_expected_chain_id`) moves - // the check upstream; this pins the current order so a change is - // noticed. +fn any_chain_id_a_platform_quorum_signed_is_accepted(protocol_version: ProtocolVersion) { + // The shell has no expected chain id: the signing quorum must be one + // the embedder pushed from its own Core chain, which pins the chain. let fixture = Fixture::get(protocol_version); - let client = mock_client(); let mut metadata = fixture.metadata(); - metadata.chain_id = "dash-mainnet".to_string(); - let proof = fixture.proof(fixture.prove_identity(fixture.alice.id()), &metadata); - install_at_floor( - &client, - &identity_request(fixture.alice.id()), - common::identity_response(proof, metadata), - ); - assert_eq!(client.platform_version().protocol_version, DEPLOYED_VERSION); - let result = ops::get_identity(&client, alice_id(fixture)); - assert_kind(&result.status, StatusKind::ChainIdMismatch); - assert_eq!( - client.platform_version().protocol_version, - fixture.version.protocol_version, - "the SDK ratcheted on the foreign chain's verified metadata" - ); - assert_eq!(client.last_seen_height(), 0, "the shell moved nothing"); - assert!( - client.verified_platform_version().is_err(), - "no transition can be built before a read the shell accepted" + metadata.chain_id = "dash-devnet-other".to_string(); + let client = identity_client(fixture, metadata); + assert_kind( + &ops::get_identity(&client, alice_id(fixture)).status, + StatusKind::Ok, ); + assert_eq!(client.last_seen_height(), HEIGHT); } #[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] From fb52ec0f17aaf0ae1b04e158de17b62898206545 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 00:23:57 -0500 Subject: [PATCH 5/5] feat(sdk)!: address review findings in dash-platform-cxx - get_contact_requests: below protocol version 14 Drive continues the (field, $createdAt) index after the cursor's creation time and skips the requests sharing it; a continuation there is Unavailable instead of a silently short list (as names_of_identity already does). The replay suite pages across a shared $createdAt at PV13 and PV14 and shows the since_ms re-read that recovers every request. - get_contested_vote_state takes a start_after cursor and returns a Page, so contests above 100 contenders are reachable; the first page carries the tallies, an empty continuation is the end, not an absence. - build_dpns_domain takes the contender count and states the fund to join that contest (PV14 doubles it past 250 contenders), as put_to_platform does; contest_fund_to_join exposes the amount. - build_contact_request refuses a to_user_id that is not the recipient before signing and no longer needs endpoints (local_sdk). - build_profile refuses a replace at revision u64::MAX instead of wrapping. - dip15_account_reference_from_mac refuses a version above 15 or an account index above 28 bits instead of truncating. - cxx_smoke.cc value-initializes every bridge struct. C++ API changes: build_dpns_domain(.., salt, contenders, key, signer), get_contested_vote_state(label, start_after), VerifiedContested.page, contest_fund_to_join(contenders), and dip15_account_reference_from_mac now throws rust::Error on out-of-range input. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/rs-platform-cxx/README.md | 24 +++- packages/rs-platform-cxx/src/builders.rs | 68 ++++++++- packages/rs-platform-cxx/src/client.rs | 30 ++++ packages/rs-platform-cxx/src/helpers.rs | 47 ++++++- packages/rs-platform-cxx/src/lib.rs | 52 ++++++- packages/rs-platform-cxx/src/ops.rs | 109 +++++++++++---- packages/rs-platform-cxx/tests/builders.rs | 101 ++++++++++++++ packages/rs-platform-cxx/tests/common/mod.rs | 40 ++++++ packages/rs-platform-cxx/tests/cxx_smoke.cc | 27 ++-- packages/rs-platform-cxx/tests/replay.rs | 138 ++++++++++++++++++- 10 files changed, 568 insertions(+), 68 deletions(-) diff --git a/packages/rs-platform-cxx/README.md b/packages/rs-platform-cxx/README.md index 012d19cc272..837b8dd72ad 100644 --- a/packages/rs-platform-cxx/README.md +++ b/packages/rs-platform-cxx/README.md @@ -74,7 +74,9 @@ generated header `dash/platform/ffi.h`. `search_names` (prefix of 1 to 63 normalized characters, limit clamped to 1..=100), `names_of_identity`, `get_profile`, `get_contact_requests` (to or from an identity, after a time, oldest first), - `get_contested_vote_state`. Paged reads return one page of up to their + `get_contested_vote_state` (up to 100 contenders a page, by identity id; + the first page also carries the tallies and the winner, a continuation + only the contenders after its cursor). Paged reads return one page of up to their limit; `page.has_more` is set on a page that fills it (which may still be the last one) and `page.next_start_after` is the cursor for the next call (all zero = from the start). Queries use this build's latest compiled-in @@ -82,25 +84,33 @@ generated header `dash/platform/ffi.h`. only matters for building. At protocol version 13 Drive answers a continuation of `names_of_identity` with an empty page, so only its first 100 names are reachable there and a continuation is `Unavailable` rather - than a last page. + than a last page. The same goes for a `get_contact_requests` continuation, + where Drive skips the requests created at the same time as the cursor; + re-reading with `since_ms` one below the last `created_at` and dropping + the requests already seen continues there. - **Broadcast**: `broadcast(&[u8]) -> BroadcastResult{status}`, advisory and typed: `Ok`, `AlreadyExists`, `Consensus` with `consensus_code`, `Rejected` (a definitive non-consensus refusal), `Unavailable` (no answer). Every write is confirmed by a proved re-query. - **Builders**, no network, returning `Built{bytes, hash, object_id}`: `build_identity_create`, `build_dpns_preorder`, `build_dpns_domain` (the - caller supplies and persists the salt), `build_profile` (create, or + caller supplies and persists the salt, and passes the number of + contenders `get_contested_vote_state` read, 0 for none: a contested name + states the fund to join that contest, `contest_fund_to_join`, which from + protocol version 14 doubles past 250 contenders), `build_profile` (create, or replace the `Profile` a `get_profile` returned at its revision + 1: a replace carries the whole document, so the avatar and payment-address fields another wallet set are carried over unedited), `build_contact_request` (minted by `Sdk::create_contact_request` with the - embedder's ECDH secret). A create carries the document id + embedder's ECDH secret; it needs no endpoints, and `to_user_id` must be + the recipient's id). A create carries the document id `dash-sdk`'s `put_to_platform` would derive at the network's version (from the entropy alone up to protocol version 13, also from the identity contract nonce from 14), and a property the network's contract does not have yet (DashPay's payment addresses before 14) is refused before anything is signed. Transition rules change across protocol - versions, so every builder (and `contested_vote_fund_credits`) needs the + versions, so every builder (and `contested_vote_fund_credits`, + `contest_fund_to_join`) needs the version a verified read has shown the network to run: before the first read the shell accepted they fail with a `rust::Error`, except on a devnet, whose floor is the latest version (regtest's is not: one read @@ -112,7 +122,9 @@ generated header `dash/platform/ffi.h`. DIP-15 pieces that need only 32-byte inputs: `dip15_decrypt_xpub`, `dip15_account_reference_from_mac`, `dip15_unmask_account_reference_from_mac`, `dip15_select_recipient_key`, - `dip15_receive_keys_acceptable`. The infallible ones answer `false`, `0` + `dip15_receive_keys_acceptable`. `dip15_account_reference_from_mac` + refuses a version above 15 or an account index above 28 bits rather than + truncate it. The infallible ones answer `false`, `0` or empty on a (contained) panic, which reads as a refusal. `Status.kind` is one of `Ok, ProvenAbsent, AlreadyExists, Consensus, diff --git a/packages/rs-platform-cxx/src/builders.rs b/packages/rs-platform-cxx/src/builders.rs index 3f13c34e4ac..74ba3abd37e 100644 --- a/packages/rs-platform-cxx/src/builders.rs +++ b/packages/rs-platform-cxx/src/builders.rs @@ -26,6 +26,7 @@ use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getter use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dash_sdk::dpp::data_contract::DataContract; use dash_sdk::dpp::document::{Document, DocumentV0, DocumentV0Getters, INITIAL_REVISION}; +use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -42,6 +43,7 @@ use dash_sdk::dpp::state_transition::batch_transition::accessors::DocumentsBatch use dash_sdk::dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; use dash_sdk::dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; use dash_sdk::dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dash_sdk::dpp::state_transition::batch_transition::BatchTransition; use dash_sdk::dpp::state_transition::identity_create_transition::methods::IdentityCreateTransitionMethodsV0; use dash_sdk::dpp::state_transition::identity_create_transition::IdentityCreateTransition; @@ -50,6 +52,7 @@ use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemData use dash_sdk::dpp::util::hash::{hash_double, hash_single}; use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars; use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::required_vote_resolution_fund_to_join; use dash_sdk::platform::dashpay::{ContactRequestInput, EcdhProvider, RecipientIdentity}; use dash_sdk::Sdk; use futures::executor::block_on; @@ -167,7 +170,12 @@ fn document_id(state_transition: &StateTransition) -> Result } enum Kind { - Create { entropy: [u8; 32] }, + /// `contest_fund`: the most a contested create states it pays into the + /// contest it joins; `None` states the contest's own fund. + Create { + entropy: [u8; 32], + contest_fund: Option, + }, Replace, } @@ -210,10 +218,17 @@ fn build_system_document( let identity_key = identity_key(key)?; let signer = BytesSigner(signer); let state_transition = match kind { - Kind::Create { entropy } => { + Kind::Create { + entropy, + contest_fund, + } => { document .set_id_for_creation(document_type, &entropy, nonce, version) .map_err(|e| format!("unable to derive the document id: {e}"))?; + let options = contest_fund.map(|contest_fund| StateTransitionCreationOptions { + contest_fund: Some(contest_fund), + ..Default::default() + }); block_on( BatchTransition::new_document_creation_transition_from_document( document, @@ -225,7 +240,7 @@ fn build_system_document( None, &signer, version, - None, + options, ), ) } @@ -330,7 +345,10 @@ pub fn build_dpns_preorder( "preorder", dpns_preorder_document(Identifier::from(owner), label, salt), nonce, - Kind::Create { entropy }, + Kind::Create { + entropy, + contest_fund: None, + }, key, signer, ) @@ -338,6 +356,13 @@ pub fn build_dpns_preorder( /// The contested-name prefund is attached by dpp from the domain type's /// contested unique index; nothing here decides whether a name is contested. +/// A contested create states the fund to join a contest of `contenders` +/// (the count the embedder read with `get_contested_vote_state`), as +/// `dash-sdk`'s put-document path does with the count it reads: from +/// protocol version 14 that fund doubles once a contest holds 250 +/// contenders and again for every 50 more, and Drive refuses a create that +/// states less (charging its fees); before 14 it is the contest's fund +/// whatever the count. An uncontested name ignores it. #[allow(clippy::too_many_arguments)] pub fn build_dpns_domain( version: &PlatformVersion, @@ -345,6 +370,7 @@ pub fn build_dpns_domain( nonce: u64, label: &str, salt: &[u8; 32], + contenders: u32, entropy: [u8; 32], key: &ffi::IdentityKey, signer: &dyn SignerCallbacks, @@ -355,12 +381,27 @@ pub fn build_dpns_domain( "domain", dpns_domain_document(Identifier::from(owner), label, salt), nonce, - Kind::Create { entropy }, + Kind::Create { + entropy, + contest_fund: Some(contest_fund_to_join(version, contenders)), + }, key, signer, ) } +/// The fund a contested DPNS name create pays to join a contest of +/// `contenders` under `version` (the contest's fund before protocol +/// version 14 and below 250 contenders). +pub fn contest_fund_to_join(version: &PlatformVersion, contenders: u32) -> Credits { + required_vote_resolution_fund_to_join( + &SystemDataContract::DPNS.id(), + "domain", + u16::try_from(contenders).unwrap_or(u16::MAX), + version, + ) +} + /// A DashPay `profile`: created when `existing` has no document id, else /// replaced at its revision + 1. A replace transition carries the whole /// document, so the fields the embedder does not edit (avatar, payment @@ -404,7 +445,10 @@ pub fn build_profile( "profile", new_document(owner, properties), nonce, - Kind::Create { entropy }, + Kind::Create { + entropy, + contest_fund: None, + }, key, signer, ); @@ -415,11 +459,15 @@ pub fn build_profile( if existing.revision < INITIAL_REVISION { return Err("the profile to replace carries no revision".to_string()); } + let revision = existing + .revision + .checked_add(1) + .ok_or("the profile to replace is at the last revision there is")?; let document = Document::V0(DocumentV0 { id: Identifier::from(existing.document_id), owner_id: owner, properties, - revision: Some(existing.revision + 1), + revision: Some(revision), ..Default::default() }); build_system_document( @@ -452,6 +500,11 @@ pub fn build_contact_request( key: &ffi::IdentityKey, signer: &dyn SignerCallbacks, ) -> Result { + // The SDK addresses the request to `recipient`; an input naming another + // identity is contradictory embedder state, refused before signing. + if input.to_user_id != recipient.id { + return Err("the contact request's to_user_id is not the recipient identity".to_string()); + } let expected_recipient_pubkey = PublicKey::from_slice(&input.recipient_pubkey) .map_err(|e| format!("bad recipient public key: {e}"))?; let shared_secret = Zeroizing::new(input.shared_secret); @@ -516,6 +569,7 @@ pub fn build_contact_request( nonce, Kind::Create { entropy: result.entropy.0, + contest_fund: None, }, key, signer, diff --git a/packages/rs-platform-cxx/src/client.rs b/packages/rs-platform-cxx/src/client.rs index 4d831654c38..0279495050b 100644 --- a/packages/rs-platform-cxx/src/client.rs +++ b/packages/rs-platform-cxx/src/client.rs @@ -273,6 +273,17 @@ impl Client { .ok_or_else(|| "no evonode endpoints".to_string()) } + /// An SDK for work that sends nothing (the contact-request builder + /// mints through one): the network SDK when there is one, otherwise one + /// over no endpoints under the same policy, which cannot dispatch. A + /// builder so does not depend on whether endpoints are set. + pub fn local_sdk(&self) -> Result { + match self.sdk() { + Ok(sdk) => Ok(sdk), + Err(_) => self.build_sdk(AddressList::new()), + } + } + /// Whether a request can be dispatched at all: the client is not shut /// down and endpoints have been pushed. `Unavailable` otherwise. pub fn check_ready(&self) -> Result<(), Status> { @@ -835,6 +846,25 @@ mod tests { client.shutdown(); } + #[test] + fn a_local_sdk_needs_no_endpoints_and_opens_nothing() { + let client = testnet_client(); + assert!(client.sdk().is_err(), "no network SDK without endpoints"); + let local = client.local_sdk().expect("a local SDK"); + assert!(local.address_list().is_empty(), "it cannot dispatch"); + assert!(client.sdk().is_err(), "and it is not installed"); + + client + .set_endpoints(&["https://1.1.1.1:1443".to_string()]) + .expect("endpoint"); + assert!(!client + .local_sdk() + .expect("the network SDK") + .address_list() + .is_empty()); + client.shutdown(); + } + #[test] fn watermark_tolerates_three_blocks() { let client = testnet_client(); diff --git a/packages/rs-platform-cxx/src/helpers.rs b/packages/rs-platform-cxx/src/helpers.rs index c87157848d5..7ec79eac783 100644 --- a/packages/rs-platform-cxx/src/helpers.rs +++ b/packages/rs-platform-cxx/src/helpers.rs @@ -75,12 +75,35 @@ fn ask28(mac: &[u8; 32]) -> u32 { u32::from_be_bytes([mac[28], mac[29], mac[30], mac[31]]) >> 4 } +/// Largest DIP-15 rotation version: it has the top four bits. +pub const MAX_ACCOUNT_REFERENCE_VERSION: u32 = 0x0F; +/// Largest account index an `accountReference` carries: the low 28 bits. +pub const MAX_ACCOUNT_REFERENCE_INDEX: u32 = 0x0FFF_FFFF; + /// Masks `account_index` into a DIP-15 `accountReference` carrying the /// rotation `version` in its top four bits (`platform-encryption`'s /// `calculate_account_reference` over a MAC Core computed with the -/// ENCRYPTION key it never exports). -pub fn dip15_account_reference_from_mac(mac: &[u8; 32], account_index: u32, version: u32) -> u32 { - (version << 28) | (ask28(mac) ^ (account_index & 0x0FFF_FFFF)) +/// ENCRYPTION key it never exports). A version or index that does not fit +/// its bits is refused: truncating it would produce a reference that does +/// not unmask to what the caller asked for. +pub fn dip15_account_reference_from_mac( + mac: &[u8; 32], + account_index: u32, + version: u32, +) -> Result { + if version > MAX_ACCOUNT_REFERENCE_VERSION { + return Err(format!( + "accountReference version {version} does not fit in four bits (at most \ + {MAX_ACCOUNT_REFERENCE_VERSION})" + )); + } + if account_index > MAX_ACCOUNT_REFERENCE_INDEX { + return Err(format!( + "account index {account_index} does not fit in 28 bits (at most \ + {MAX_ACCOUNT_REFERENCE_INDEX})" + )); + } + Ok((version << 28) | (ask28(mac) ^ account_index)) } /// Inverse of [`dip15_account_reference_from_mac`] for the same MAC. @@ -219,7 +242,8 @@ mod tests { assert_eq!(ask28(&mac), 0x01c1_d1e1); for version in [0u32, 1, 7, 15] { for account in [0u32, 1, 5, 0x0FFF_FFFF] { - let reference = dip15_account_reference_from_mac(&mac, account, version); + let reference = + dip15_account_reference_from_mac(&mac, account, version).expect("in range"); let unmasked = dip15_unmask_account_reference_from_mac(&mac, reference); assert_eq!( (unmasked.version, unmasked.account_index), @@ -227,7 +251,20 @@ mod tests { ); } } - assert_eq!(dip15_account_reference_from_mac(&mac, 0, 0), 0x01c1_d1e1); + assert_eq!( + dip15_account_reference_from_mac(&mac, 0, 0), + Ok(0x01c1_d1e1) + ); + } + + #[test] + fn account_reference_refuses_what_its_bits_cannot_carry() { + let mac = [7u8; 32]; + // Version 16 would have come back as 0, a high index as another one. + assert!(dip15_account_reference_from_mac(&mac, 0, 16).is_err()); + assert!(dip15_account_reference_from_mac(&mac, 0, u32::MAX).is_err()); + assert!(dip15_account_reference_from_mac(&mac, 0x1000_0000, 0).is_err()); + assert!(dip15_account_reference_from_mac(&mac, 0x0FFF_FFFF, 15).is_ok()); } #[test] diff --git a/packages/rs-platform-cxx/src/lib.rs b/packages/rs-platform-cxx/src/lib.rs index dd5d429d89f..1f553d3a7a6 100644 --- a/packages/rs-platform-cxx/src/lib.rs +++ b/packages/rs-platform-cxx/src/lib.rs @@ -291,11 +291,15 @@ pub mod ffi { page: Page, } + /// One page of a contest: the first (`start_after` all zero) carries + /// the tallies, winner and `ends_at` with up to 100 contenders; a + /// continuation carries only the contenders after its cursor. #[derive(Debug, Clone, Default)] struct VerifiedContested { status: Status, meta: Meta, value: ContestedState, + page: Page, } /// The node's answer to a broadcast: advisory, never proof-backed. @@ -470,6 +474,9 @@ pub mod ffi { fn get_profile(self: &PlatformClient, owner: &[u8; 32]) -> VerifiedProfile; /// One page of up to 100 requests sent to (`to_me`) or by /// `identity`, created after `since_ms` (0 = all), oldest first. + /// Below protocol version 14 a continuation is `Unavailable`: Drive + /// skips the requests sharing the cursor's creation time. Continue + /// there with `since_ms` one below the last `created_at` instead. fn get_contact_requests( self: &PlatformClient, identity: &[u8; 32], @@ -477,9 +484,12 @@ pub mod ffi { since_ms: u64, start_after: &[u8; 32], ) -> VerifiedContactRequests; + /// One page of up to 100 contenders, by identity id, plus the + /// tallies on the first page; `start_after` all zero starts over. fn get_contested_vote_state( self: &PlatformClient, normalized_label: &str, + start_after: &[u8; 32], ) -> VerifiedContested; // --- Broadcast -------------------------------------------------------- @@ -504,12 +514,17 @@ pub mod ffi { key: &IdentityKey, signer: &WalletSigner, ) -> Result; + /// `contenders`: how many contenders the name's contest holds (0 for + /// none), as `get_contested_vote_state` read them. A contested name + /// states the fund to join that contest (`contest_fund_to_join`). + #[allow(clippy::too_many_arguments)] fn build_dpns_domain( self: &PlatformClient, owner: &[u8; 32], nonce: u64, label: &str, salt: &[u8; 32], + contenders: u32, key: &IdentityKey, signer: &WalletSigner, ) -> Result; @@ -543,14 +558,19 @@ pub mod ffi { /// version a verified read has shown the network to run; an error /// before that (the amount changed across versions). fn contested_vote_fund_credits(self: &PlatformClient) -> Result; + /// Credits a contested DPNS name create pays to join a contest of + /// `contenders` at the verified protocol version: from protocol + /// version 14 the fund doubles past 250 contenders. + fn contest_fund_to_join(self: &PlatformClient, contenders: u32) -> Result; fn credits_per_duff() -> u64; fn system_contract_id(which: SystemContract) -> Result<[u8; 32]>; fn dip15_decrypt_xpub(shared_secret: &[u8; 32], ciphertext: &[u8]) -> Result; + /// An error when `version` exceeds 15 or `account_index` 28 bits. fn dip15_account_reference_from_mac( mac: &[u8; 32], account_index: u32, version: u32, - ) -> u32; + ) -> Result; fn dip15_unmask_account_reference_from_mac(mac: &[u8; 32], reference: u32) -> AccountRef; fn dip15_select_recipient_key(identity: &Identity) -> Result; fn dip15_receive_keys_acceptable( @@ -761,10 +781,14 @@ impl PlatformClient { ) } - fn get_contested_vote_state(&self, normalized_label: &str) -> ffi::VerifiedContested { + fn get_contested_vote_state( + &self, + normalized_label: &str, + start_after: &[u8; 32], + ) -> ffi::VerifiedContested { guarded( "get_contested_vote_state", - || ops::get_contested_vote_state(&self.0, normalized_label), + || ops::get_contested_vote_state(&self.0, normalized_label, cursor(start_after)), ops::failed, ) } @@ -818,12 +842,14 @@ impl PlatformClient { }) } + #[allow(clippy::too_many_arguments)] fn build_dpns_domain( &self, owner: &[u8; 32], nonce: u64, label: &str, salt: &[u8; 32], + contenders: u32, key: &ffi::IdentityKey, signer: &ffi::WalletSigner, ) -> Result { @@ -834,6 +860,7 @@ impl PlatformClient { nonce, label, salt, + contenders, builders::fresh_entropy(), key, signer, @@ -875,7 +902,7 @@ impl PlatformClient { ) -> Result { fallible("build_contact_request", || { let version = self.0.verified_platform_version()?; - let sdk = self.0.sdk()?; + let sdk = self.0.local_sdk()?; builders::build_contact_request( &sdk, version, sender, recipient, nonce, input, key, signer, ) @@ -889,6 +916,15 @@ impl PlatformClient { )) }) } + + fn contest_fund_to_join(&self, contenders: u32) -> Result { + fallible("contest_fund_to_join", || { + Ok(builders::contest_fund_to_join( + self.0.verified_platform_version()?, + contenders, + )) + }) + } } /// A paging cursor: all zero means "from the start". @@ -928,8 +964,12 @@ fn dip15_decrypt_xpub( }) } -fn dip15_account_reference_from_mac(mac: &[u8; 32], account_index: u32, version: u32) -> u32 { - guarded_default("dip15_account_reference_from_mac", || { +fn dip15_account_reference_from_mac( + mac: &[u8; 32], + account_index: u32, + version: u32, +) -> Result { + fallible("dip15_account_reference_from_mac", || { helpers::dip15_account_reference_from_mac(mac, account_index, version) }) } diff --git a/packages/rs-platform-cxx/src/ops.rs b/packages/rs-platform-cxx/src/ops.rs index 4fd2c3522d0..109e3b43035 100644 --- a/packages/rs-platform-cxx/src/ops.rs +++ b/packages/rs-platform-cxx/src/ops.rs @@ -632,6 +632,13 @@ pub fn get_profile(client: &Client, owner: [u8; 32]) -> ffi::VerifiedProfile { /// One page of the contact requests sent to (`to_me`) or by `identity`, /// created after `since_ms`, oldest first. The `$createdAt` order pins the /// contract's `(field, $createdAt)` index, which a bare equality would not. +/// Up to protocol version 13 Drive continues this index after the cursor's +/// `$createdAt`, skipping the requests created at the same time as the +/// cursor that sort after it (requests from one block share it): a +/// continuation answered there is `Unavailable` instead, so no request is +/// silently left out. Re-reading from `since_ms` = the last request's +/// `created_at` - 1, without a cursor, and dropping the requests already +/// seen continues safely. pub fn get_contact_requests( client: &Client, identity: [u8; 32], @@ -655,22 +662,36 @@ pub fn get_contact_requests( .with_order_by(ascending("$createdAt")) .with_limit(PAGE_SIZE) }); - page(client, query, contact_request, |items, page| { + let mut result = page(client, query, contact_request, |items, page| { ffi::VerifiedContactRequests { items, page, ..Default::default() } - }) + }); + if cursor.is_some() + && result.status.kind == StatusKind::Ok + && result.meta.protocol_version < PROTOCOL_VERSION_14 + { + result.status = Status::unavailable(format!( + "protocol version {} cannot continue a contact request page after a cursor \ + (fixed in {}): read again with since_ms one below the last request's created_at", + result.meta.protocol_version, PROTOCOL_VERSION_14 + )); + } + result } // --- contested names ------------------------------------------------------- /// A finished or empty contest is a `Contenders` with no contenders; the /// SDK folds a proven-absent contest into the same shape, so absence is -/// read off the tallies: a real contest always carries them. -fn contested_state(contenders: Contenders) -> Option { - if contenders.contenders.is_empty() +/// read off the tallies: a real contest always carries them. Only the first +/// page does, though: a `continuation` (contenders after a cursor, which +/// the first page proved to be in a contest) is never an absence. +fn contested_state(contenders: Contenders, continuation: bool) -> Option { + if !continuation + && contenders.contenders.is_empty() && contenders.winner.is_none() && contenders.abstain_vote_tally.is_none() && contenders.lock_vote_tally.is_none() @@ -707,9 +728,17 @@ fn contested_state(contenders: Contenders) -> Option { Some(state) } -/// The vote state of the contested name `normalized_label` (tallies, -/// including abstain and lock); `ProvenAbsent` = no contest. -pub fn get_contested_vote_state(client: &Client, normalized_label: &str) -> ffi::VerifiedContested { +/// One page of the vote state of the contested name `normalized_label`, +/// contenders ordered by identity id; `ProvenAbsent` = no contest. The +/// first page (no `cursor`) carries the tallies, including abstain and +/// lock, and the winner; Drive answers a continuation with the contenders +/// after the cursor only. A page that fills [`CONTESTED_VOTE_COUNT`] +/// reports more, as a document page does. +pub fn get_contested_vote_state( + client: &Client, + normalized_label: &str, + cursor: Option<[u8; 32]>, +) -> ffi::VerifiedContested { let query = ContestedDocumentVotePollDriveQuery { vote_poll: ContestedDocumentResourceVotePoll { contract_id: SystemDataContract::DPNS.id(), @@ -722,20 +751,30 @@ pub fn get_contested_vote_state(client: &Client, normalized_label: &str) -> ffi: }, result_type: ContestedDocumentVotePollDriveQueryResultType::VoteTally, allow_include_locked_and_abstaining_vote_tally: true, - start_at: None, + start_at: cursor.map(|id| (id, false)), limit: Some(CONTESTED_VOTE_COUNT), offset: None, }; + let continuation = cursor.is_some(); fetch( client, move |sdk| async move { ContenderWithSerializedDocument::fetch_many_with_metadata(&sdk, query, None) .await - .map(|(contenders, metadata)| (contested_state(contenders), metadata)) + .map(|(contenders, metadata)| (contested_state(contenders, continuation), metadata)) }, |value| { + let page = ffi::Page { + next_start_after: value + .contenders + .last() + .map(|contender| contender.identity) + .unwrap_or_default(), + has_more: value.contenders.len() >= usize::from(CONTESTED_VOTE_COUNT), + }; Ok(ffi::VerifiedContested { value, + page, ..Default::default() }) }, @@ -847,23 +886,32 @@ mod tests { #[test] fn empty_contenders_read_as_no_contest() { - assert!(contested_state(Contenders::default()).is_none()); + assert!(contested_state(Contenders::default(), false).is_none()); + } + + #[test] + fn an_empty_continuation_is_the_end_of_a_contest_not_absence() { + let state = contested_state(Contenders::default(), true).expect("a page"); + assert!(state.contenders.is_empty()); } #[test] fn a_finished_poll_without_contenders_is_still_a_contest() { - let state = contested_state(Contenders { - winner: Some(( - ContestedDocumentVotePollWinnerInfo::Locked, - BlockInfo { - time_ms: 77, - ..Default::default() - }, - )), - contenders: Default::default(), - abstain_vote_tally: Some(0), - lock_vote_tally: Some(3), - }) + let state = contested_state( + Contenders { + winner: Some(( + ContestedDocumentVotePollWinnerInfo::Locked, + BlockInfo { + time_ms: 77, + ..Default::default() + }, + )), + contenders: Default::default(), + abstain_vote_tally: Some(0), + lock_vote_tally: Some(3), + }, + false, + ) .expect("a contest"); assert_eq!(state.winner_kind, ffi::WinnerKind::Locked); assert_eq!((state.lock, state.ends_at), (3, 77)); @@ -871,12 +919,15 @@ mod tests { #[test] fn zero_tallies_are_a_contest_not_absence() { - assert!(contested_state(Contenders { - winner: None, - contenders: Default::default(), - abstain_vote_tally: Some(0), - lock_vote_tally: Some(0), - }) + assert!(contested_state( + Contenders { + winner: None, + contenders: Default::default(), + abstain_vote_tally: Some(0), + lock_vote_tally: Some(0), + }, + false + ) .is_some()); } diff --git a/packages/rs-platform-cxx/tests/builders.rs b/packages/rs-platform-cxx/tests/builders.rs index 817980fdaef..7978ffc7c42 100644 --- a/packages/rs-platform-cxx/tests/builders.rs +++ b/packages/rs-platform-cxx/tests/builders.rs @@ -71,6 +71,7 @@ use dash_sdk::dpp::system_data_contracts::SystemDataContract; use dash_sdk::dpp::tests::fixtures::instant_asset_lock_proof_fixture; use dash_sdk::dpp::util::hash::{hash_double, hash_single}; use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars; +use dash_sdk::dpp::version::v14::PROTOCOL_VERSION_14; use dash_sdk::dpp::version::{PlatformVersion, ProtocolVersion, LATEST_VERSION}; use dash_sdk::platform::dashpay::{ ContactRequestInput as SdkContactRequestInput, EcdhProvider, RecipientIdentity, @@ -410,6 +411,7 @@ fn dpns_domain_matches_register_dpns_name_for_the_same_salt(protocol_version: Pr 8, "Alice", &salt, + 0, entropy, &high_key(), &wallet, @@ -481,6 +483,65 @@ fn dpns_domain_matches_register_dpns_name_for_the_same_salt(protocol_version: Pr ); } +/// The fund a built domain create states into its contest. +fn stated_contest_fund(built: &ffi::Built) -> Option { + let (state_transition, _, _) = document_transition(&built.bytes); + let StateTransition::Batch(batch) = &state_transition else { + unreachable!() + }; + let Some(BatchedTransitionRef::Document(transition)) = batch.first_transition() else { + unreachable!() + }; + let DocumentTransition::Create(create) = transition else { + panic!("not a create") + }; + create + .prefunded_voting_balance() + .as_ref() + .map(|(_, credits)| *credits) +} + +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn contested_domain_states_the_fund_to_join_its_contest(protocol_version: ProtocolVersion) { + let version = version(protocol_version); + let base = dash_platform_cxx::helpers::contested_vote_fund_credits(version); + let build = |contenders: u32| { + builders::build_dpns_domain( + version, + owner().to_buffer(), + 8, + "Alice", + &[0x55u8; 32], + contenders, + [0x66u8; 32], + &high_key(), + &Wallet::new(), + ) + .expect("domain") + }; + // A contest below 250 contenders takes its fund at every version. + assert_eq!(stated_contest_fund(&build(0)), Some(base)); + assert_eq!(stated_contest_fund(&build(249)), Some(base)); + // From protocol version 14 the fund doubles at 250 contenders and + // again for every 50 more; Drive refuses a create that states less. + // Before 14 it never doubles, and Drive wants the fund exactly. + let doubled = |times: u32| { + if protocol_version >= PROTOCOL_VERSION_14 { + base << times + } else { + base + } + }; + assert_eq!(stated_contest_fund(&build(250)), Some(doubled(1))); + assert_eq!(stated_contest_fund(&build(300)), Some(doubled(2))); + for contenders in [0, 250, 300] { + assert_eq!( + stated_contest_fund(&build(contenders)), + Some(builders::contest_fund_to_join(version, contenders)) + ); + } +} + #[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] fn uncontested_domain_carries_no_prefund(protocol_version: ProtocolVersion) { let version = version(protocol_version); @@ -491,6 +552,7 @@ fn uncontested_domain_carries_no_prefund(protocol_version: ProtocolVersion) { 1, "alice-2024", &[1u8; 32], + 300, [2u8; 32], &high_key(), &wallet, @@ -693,6 +755,24 @@ fn profile_create_and_replace(protocol_version: ProtocolVersion) { &wallet, ) .is_err()); + // Nor one at the last revision, which has no next one to replace it at. + let last = ffi::Profile { + revision: u64::MAX, + ..existing.clone() + }; + let refused = Wallet::new(); + assert!(builders::build_profile( + version, + owner().to_buffer(), + 5, + &last, + &replace, + entropy, + &high_key(), + &refused, + ) + .is_err()); + assert!(refused.requests().is_empty()); // A field the network's contract does not have is refused before // anything is signed, not after Drive charged for it. @@ -1018,6 +1098,26 @@ fn contact_request_matches_send_contact_request_for_the_same_secret( .unwrap_err(); assert!(error.contains("recipient key"), "{error}"); assert!(refused.requests().is_empty()); + + // So does an input addressed to another identity than the recipient. + let elsewhere = ContactRequestInput { + to_user_id: [0x5au8; 32], + ..input.clone() + }; + let refused = Wallet::new(); + let error = builders::build_contact_request( + &sdk, + version, + &ffi_identity(&sender), + &ffi_identity(&recipient), + 9, + &elsewhere, + &high_key(), + &refused, + ) + .unwrap_err(); + assert!(error.contains("to_user_id"), "{error}"); + assert!(refused.requests().is_empty()); } // --- identity create ------------------------------------------------------- @@ -1381,6 +1481,7 @@ fn builders_never_enter_a_tokio_runtime(protocol_version: ProtocolVersion) { 2, "alice", &[1u8; 32], + 0, [2u8; 32], &high_key(), &wallet, diff --git a/packages/rs-platform-cxx/tests/common/mod.rs b/packages/rs-platform-cxx/tests/common/mod.rs index 8f3ef386359..164f4686347 100644 --- a/packages/rs-platform-cxx/tests/common/mod.rs +++ b/packages/rs-platform-cxx/tests/common/mod.rs @@ -90,6 +90,21 @@ pub const CORE_PAYMENT_ADDRESS: [u8; 21] = [0x1a; 21]; /// Names the third identity owns: one more than a page. pub const PAGED_NAME_COUNT: usize = dash_platform_cxx::ops::PAGE_SIZE as usize + 1; +/// The sender and the recipient of the paged contact requests, which +/// nothing else in the fixture involves. +pub const PAGED_CONTACT_OWNER: [u8; 32] = [0x66; 32]; +pub const PAGED_CONTACT_RECIPIENT: [u8; 32] = [0x77; 32]; +/// `$createdAt` of the paged contact requests: a page boundary falls inside +/// a group of requests created in the same block, with a later block after. +pub const PAGED_CONTACT_CREATED_AT: [(u64, usize); 3] = [ + ( + 1_700_000_100_000, + dash_platform_cxx::ops::PAGE_SIZE as usize - 1, + ), + (1_700_000_200_000, 3), + (1_700_000_300_000, 2), +]; + /// The labels of the third identity's names, none of them contested (the /// `2` keeps them out of the contested pattern). pub fn paged_label(i: usize) -> String { @@ -464,6 +479,31 @@ impl Fixture { ) .expect("contact request"); insert_document(&drive, &dashpay, "contactRequest", &contact, version); + let mut account_reference = 0u32; + for (created_at, count) in PAGED_CONTACT_CREATED_AT { + for _ in 0..count { + account_reference += 1; + let paged = contact_type + .create_document_from_data( + platform_value!({ + "toUserId": Identifier::from(PAGED_CONTACT_RECIPIENT), + "encryptedPublicKey": Value::Bytes(vec![0x42u8; 96]), + "senderKeyIndex": 2u32, + "recipientKeyIndex": 3u32, + "accountReference": account_reference, + "$createdAt": created_at, + "$createdAtCoreBlockHeight": CORE_CHAIN_LOCKED_HEIGHT, + }), + Identifier::from(PAGED_CONTACT_OWNER), + HEIGHT, + CORE_CHAIN_LOCKED_HEIGHT, + rng.gen(), + version, + ) + .expect("paged contact request"); + insert_document(&drive, &dashpay, "contactRequest", &paged, version); + } + } let alice_key0_hash = alice.public_keys()[&0].public_key_hash().expect("key hash"); Fixture { diff --git a/packages/rs-platform-cxx/tests/cxx_smoke.cc b/packages/rs-platform-cxx/tests/cxx_smoke.cc index e2cfbaeac9c..5101712e878 100644 --- a/packages/rs-platform-cxx/tests/cxx_smoke.cc +++ b/packages/rs-platform-cxx/tests/cxx_smoke.cc @@ -33,7 +33,7 @@ bool is_kind(const platform_ffi::Status& status, platform_ffi::StatusKind kind) int main() { - platform_ffi::Config cfg; + platform_ffi::Config cfg{}; cfg.network = 1; cfg.platform_llmq_type = 106; cfg.proxy.kind = 0; @@ -89,7 +89,7 @@ int main() // Trust inputs round-trip. The slices view plain C++ containers: the // bridge takes `&[T]`, so no rust::Vec instantiation is needed. try { - platform_ffi::QuorumKey key; + platform_ffi::QuorumKey key{}; for (std::size_t i = 0; i < key.hash.size(); ++i) key.hash[i] = static_cast(i); for (std::size_t i = 0; i < key.pubkey.size(); ++i) key.pubkey[i] = static_cast(i); const std::vector keys{key}; @@ -136,7 +136,7 @@ int main() if (expect_error("contested_vote_fund_credits before a verified read", [&] { client->contested_vote_fund_credits(); })) return 1; - platform_ffi::Config devnet_cfg; + platform_ffi::Config devnet_cfg{}; devnet_cfg.network = 2; devnet_cfg.platform_llmq_type = 106; rust::Box devnet = platform_ffi::new_platform_client(devnet_cfg); @@ -154,7 +154,7 @@ int main() signer_called = true; return false; }); - platform_ffi::IdentityKey key; + platform_ffi::IdentityKey key{}; key.id = 1; key.purpose = 0; key.security_level = 2; @@ -170,9 +170,9 @@ int main() })) return 1; if (expect_error("build_contact_request before a verified read", [&] { - platform_ffi::Identity sender; + platform_ffi::Identity sender{}; sender.id = id; - platform_ffi::ContactRequestInput input; + platform_ffi::ContactRequestInput input{}; input.to_user_id = id; client->build_contact_request(sender, sender, std::uint64_t{1}, input, key, signer); })) @@ -184,24 +184,24 @@ int main() return 1; if (!signer_called) return fail("the devnet builder never reached the signer"); if (expect_error("build_dpns_domain", [&] { - devnet->build_dpns_domain(id, std::uint64_t{1}, "alice", id, key, signer); + devnet->build_dpns_domain(id, std::uint64_t{1}, "alice", id, std::uint32_t{0}, key, signer); })) return 1; if (expect_error("build_profile", [&] { - platform_ffi::Profile existing; + platform_ffi::Profile existing{}; existing.document_id = id; existing.owner = id; existing.revision = 1; - platform_ffi::ProfileInput profile; + platform_ffi::ProfileInput profile{}; profile.display_name = "name"; devnet->build_profile(id, std::uint64_t{1}, existing, profile, key, signer); })) return 1; if (expect_error("build_contact_request", [&] { - platform_ffi::Identity sender; + platform_ffi::Identity sender{}; sender.id = id; platform_ffi::Identity recipient = sender; - platform_ffi::ContactRequestInput input; + platform_ffi::ContactRequestInput input{}; input.to_user_id = id; input.sender_key_index = 2; input.recipient_key_index = 2; @@ -210,7 +210,7 @@ int main() })) return 1; if (expect_error("build_identity_create", [&] { - platform_ffi::AssetLockProofInput proof; + platform_ffi::AssetLockProofInput proof{}; proof.is_instant = false; proof.core_chain_locked_height = 1; const std::vector keys; @@ -232,6 +232,9 @@ int main() const std::uint32_t reference = platform_ffi::dip15_account_reference_from_mac(mac, 5, 3); const auto unmasked = platform_ffi::dip15_unmask_account_reference_from_mac(mac, reference); if (unmasked.version != 3 || unmasked.account_index != 5) return fail("account reference"); + if (expect_error("an accountReference version above 15", + [&] { platform_ffi::dip15_account_reference_from_mac(mac, 5, 16); })) + return 1; if (platform_ffi::dip15_receive_keys_acceptable(1, 2, 0)) return fail("key 0 accepted"); if (!platform_ffi::dip15_receive_keys_acceptable(1, 2, 3)) return fail("keys refused"); diff --git a/packages/rs-platform-cxx/tests/replay.rs b/packages/rs-platform-cxx/tests/replay.rs index 43cf38b4fb8..d35fa3708c9 100644 --- a/packages/rs-platform-cxx/tests/replay.rs +++ b/packages/rs-platform-cxx/tests/replay.rs @@ -20,7 +20,8 @@ use common::{ install_identity, install_ok, install_refusal, mock_client, mock_client_at, nonce_request, nonce_response, now_ms, offline_sdk, paged_label, quorum, Fixture, ABSENT_LABEL, CONTESTED_LABEL, CORE_CHAIN_LOCKED_HEIGHT, DEPLOYED_VERSION, DPNS_NONCE, HEIGHT, OWNED_LABEL, - PAGED_NAME_COUNT, PLATFORM_LLMQ_TYPE, + PAGED_CONTACT_CREATED_AT, PAGED_CONTACT_OWNER, PAGED_CONTACT_RECIPIENT, PAGED_NAME_COUNT, + PLATFORM_LLMQ_TYPE, }; use dash_platform_cxx::client::{Client, HEIGHT_TOLERANCE}; use dash_platform_cxx::ffi::{StatusKind, WinnerKind}; @@ -983,6 +984,89 @@ fn contact_requests_to_me_and_from_me(protocol_version: ProtocolVersion) { ); } +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn contact_requests_page_across_a_shared_creation_time(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let total: usize = PAGED_CONTACT_CREATED_AT + .iter() + .map(|(_, count)| count) + .sum(); + for (field, identity, to_me) in [ + ("toUserId", PAGED_CONTACT_RECIPIENT, true), + ("$ownerId", PAGED_CONTACT_OWNER, false), + ] { + let client = mock_client(); + let query = DocumentQuery::new(fixture.dashpay().clone(), "contactRequest") + .expect("query") + .with_where(WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity), + }) + .with_order_by(OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }) + .with_limit(ops::PAGE_SIZE); + install_documents(fixture, &client, &query); + let first = ops::get_contact_requests(&client, identity, to_me, 0, None); + assert_kind(&first.status, StatusKind::Ok); + assert_eq!(first.items.len(), ops::PAGE_SIZE as usize, "{field}"); + assert!(first.page.has_more); + // The page ends inside the group created in the same block. + let (boundary, _) = PAGED_CONTACT_CREATED_AT[1]; + assert_eq!(first.items.last().expect("an item").created_at, boundary); + + let cursor = first.page.next_start_after; + install_documents(fixture, &client, &continuation(query.clone(), cursor)); + let mut rest = ops::get_contact_requests(&client, identity, to_me, 0, Some(cursor)); + if protocol_version < PROTOCOL_VERSION_14 { + // Drive skips the rest of the boundary's creation time here (102 + // of 104), so the shell refuses the continuation rather than + // report a short list as complete... + assert_kind(&rest.status, StatusKind::Unavailable); + // ...and re-reading from just below that time, without a + // cursor, recovers every request. + let since = boundary - 1; + let overlap = query.with_where(WhereClause { + field: "$createdAt".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::U64(since), + }); + install_documents(fixture, &client, &overlap); + rest = ops::get_contact_requests(&client, identity, to_me, since, None); + let seen: Vec<[u8; 32]> = first.items.iter().map(|r| r.document_id).collect(); + rest.items + .retain(|request| !seen.contains(&request.document_id)); + } + assert_kind(&rest.status, StatusKind::Ok); + assert!(!rest.page.has_more); + // The two pages partition the requests: no overlap, nothing skipped. + let mut ids: Vec<[u8; 32]> = first + .items + .iter() + .chain(rest.items.iter()) + .map(|request| request.document_id) + .collect(); + assert_eq!( + ids.len(), + total, + "{field}: {} after the cursor", + rest.items.len() + ); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), total, "{field}"); + let created: Vec = first + .items + .iter() + .chain(rest.items.iter()) + .map(|request| request.created_at) + .collect(); + assert!(created.is_sorted(), "{field}: oldest first"); + } +} + // --- contested names --------------------------------------------------------- #[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] @@ -1001,7 +1085,7 @@ fn contested_vote_state_tallies_and_absence(protocol_version: ProtocolVersion) { ); }; install_contest(&client, CONTESTED_LABEL); - let result = ops::get_contested_vote_state(&client, CONTESTED_LABEL); + let result = ops::get_contested_vote_state(&client, CONTESTED_LABEL, None); assert_kind(&result.status, StatusKind::Ok); let state = result.value; assert_eq!(state.winner_kind, WinnerKind::NoWinner); @@ -1019,10 +1103,58 @@ fn contested_vote_state_tallies_and_absence(protocol_version: ProtocolVersion) { assert_eq!(votes(fixture.bob.id().to_buffer()), Some((1, true))); install_contest(&client, ABSENT_LABEL); - let absent = ops::get_contested_vote_state(&client, ABSENT_LABEL); + let absent = ops::get_contested_vote_state(&client, ABSENT_LABEL, None); assert_kind(&absent.status, StatusKind::ProvenAbsent); } +#[test_matrix([DEPLOYED_VERSION, LATEST_VERSION])] +fn contested_vote_state_pages_by_contender(protocol_version: ProtocolVersion) { + let fixture = Fixture::get(protocol_version); + let client = mock_client(); + let label = dash_platform_cxx::helpers::normalize_label(CONTESTED_LABEL); + let install_page = |cursor: Option<[u8; 32]>| { + let mut query = fixture.contested_query(&label); + query.start_at = cursor.map(|id| (id, false)); + let metadata = fixture.metadata(); + let proof = fixture.proof(fixture.prove_contested_vote_state(query.clone()), &metadata); + install_ok( + fixture, + &client, + &contested_request(fixture, &query), + contested_response(proof, metadata), + ); + }; + install_page(None); + let first = ops::get_contested_vote_state(&client, CONTESTED_LABEL, None); + assert_kind(&first.status, StatusKind::Ok); + assert_eq!(first.value.contenders.len(), 2); + assert!(!first.page.has_more, "a short page is the last one"); + let ids: Vec<[u8; 32]> = first + .value + .contenders + .iter() + .map(|contender| contender.identity) + .collect(); + assert!(ids.is_sorted(), "contenders come in identity id order"); + assert_eq!(first.page.next_start_after, ids[1]); + + // A continuation after the first contender: only the second, and no + // tallies, which come with the first page. + install_page(Some(ids[0])); + let rest = ops::get_contested_vote_state(&client, CONTESTED_LABEL, Some(ids[0])); + assert_kind(&rest.status, StatusKind::Ok); + assert_eq!(rest.value.contenders.len(), 1); + assert_eq!(rest.value.contenders[0].identity, ids[1]); + assert_eq!((rest.value.abstain, rest.value.lock), (0, 0)); + + // After the last one the contest is over, not absent. + install_page(Some(ids[1])); + let end = ops::get_contested_vote_state(&client, CONTESTED_LABEL, Some(ids[1])); + assert_kind(&end.status, StatusKind::Ok); + assert!(end.value.contenders.is_empty()); + assert!(!end.page.has_more); +} + // --- broadcast --------------------------------------------------------------- fn broadcast_request(bytes: &[u8]) -> proto::BroadcastStateTransitionRequest {