diff --git a/.github/package-filters/rs-packages-direct.yml b/.github/package-filters/rs-packages-direct.yml index 441c8137023..3146013aae8 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -121,6 +121,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 90835d0429f..722bd5ae53b 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -130,6 +130,17 @@ platform-encryption: &platform_encryption dash-platform-queries: &platform_queries - packages/dash-platform-queries/** +dash-platform-cxx: + - packages/rs-platform-cxx/** + - *platform_queries + - *context_provider + - *dpp + - *drive + - *dapi_grpc + - packages/rs-drive-proof-verifier/** + - packages/rs-sdk/** + - packages/rs-dapi-client/** + dash-sdk: &sdk - packages/rs-drive-proof-verifier/** - packages/rs-sdk/** diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index 6fae2aa84ab..39b2ccfa832 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -155,6 +155,18 @@ dash-platform-queries: &platform_queries - .github/workflows/tests* - packages/dash-platform-queries/** +dash-platform-cxx: + - .github/workflows/tests* + - packages/rs-platform-cxx/** + - *platform_queries + - *context_provider + - *dpp + - *drive + - *dapi_grpc + - packages/rs-drive-proof-verifier/** + - packages/rs-sdk/** + - packages/rs-dapi-client/** + dash-sdk: &sdk - .github/workflows/tests* - packages/rs-drive-proof-verifier/** diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 6e64434f886..39ebaba3e5d 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -226,6 +226,13 @@ jobs: done done + # The C++ embedding surface must link and run from the staged headers + # and archive alone; the Rust tests cannot see a broken header layout. + # (dash-platform-cxx wraps the full dash-sdk, networking included, so it + # is deliberately absent from the transport-free cuts above.) + - 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: | @@ -342,6 +349,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/Cargo.lock b/Cargo.lock index 0f726d3c274..fb075dde9b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -576,7 +576,7 @@ dependencies = [ "bitflags 2.13.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -1211,6 +1211,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" @@ -1549,6 +1560,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 = "4.2.0-dev.8" @@ -1693,6 +1766,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "dash-platform-cxx" +version = "4.2.0-dev.8" +dependencies = [ + "async-trait", + "cxx", + "cxx-build", + "dash-context-provider", + "dash-platform-cxx", + "dash-platform-queries", + "dash-sdk", + "dpp", + "drive", + "futures", + "hex", + "platform-version", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "dash-platform-macros" version = "4.2.0-dev.8" @@ -4310,6 +4404,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" @@ -5582,8 +5685,8 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.4.1", - "itertools 0.10.5", + "heck 0.5.0", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -5604,7 +5707,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", @@ -5617,7 +5720,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", @@ -6793,6 +6896,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" @@ -7461,6 +7570,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -7583,6 +7703,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" @@ -8399,6 +8528,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" diff --git a/Cargo.toml b/Cargo.toml index 5fc7a2a957c..43e930b98ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ members = [ "packages/document-history-contract", "packages/keyword-search-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/dash-platform-queries/README.md b/packages/dash-platform-queries/README.md index 1587d9a58df..f5c44ec5902 100644 --- a/packages/dash-platform-queries/README.md +++ b/packages/dash-platform-queries/README.md @@ -27,13 +27,15 @@ downstream code may need to: Embedders that bring their own transport and trust context and only need the verification/query layer: -- **Dash Core's platform GUI** — fetches over its own gRPC-Web transport, - serves quorum keys from its locally synced LLMQ state via a - [`ContextProvider`](../rs-context-provider), and verifies every response - proof with [`drive-proof-verifier`](../rs-drive-proof-verifier). - Block explorers, Electrum-style servers, hardware-wallet tooling — anything that talks to DAPI its own way but must not trust responses. +Dash Core's platform GUI consumes the full `dash-sdk` instead, through +[`dash-platform-cxx`](../rs-platform-cxx): it serves quorum keys from its +locally synced LLMQ state via a [`ContextProvider`](../rs-context-provider) +and signatures from its wallet, and lets the SDK own transport, retries and +proof verification. + If you want networking, retries, and a managed connection pool, use `dash-sdk` — it consumes this crate internally. diff --git a/packages/rs-platform-cxx/Cargo.toml b/packages/rs-platform-cxx/Cargo.toml new file mode 100644 index 00000000000..e5b99642648 --- /dev/null +++ b/packages/rs-platform-cxx/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "dash-platform-cxx" +version.workspace = true +authors = ["Dash Core Group "] +edition = "2021" +rust-version.workspace = true +license = "MIT" +description = "CXX bindings 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 canned 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", +] } +dash-context-provider = { path = "../rs-context-provider", default-features = false } +dash-platform-queries = { path = "../dash-platform-queries", default-features = false } +dpp = { path = "../rs-dpp", default-features = false, features = [ + "state-transitions", + "state-transition-signing", + "state-transition-validation", + "identity-serialization", + "identity-hashing", + "bls-signatures", + "dpns-contract", + "dashpay-contract", +] } +drive = { path = "../rs-drive", default-features = false, features = ["verify"] } +platform-version = { path = "../rs-platform-version" } +tokio = { version = "1.40", features = ["rt-multi-thread", "net", "time"] } +hex = "0.4" +futures = "0.3" +async-trait = "0.1" + +[build-dependencies] +cxx-build = "1.0" + +[dev-dependencies] +dash-platform-cxx = { path = ".", features = ["mocks"] } +serde_json = "1" +serde = { version = "1", features = ["derive"] } diff --git a/packages/rs-platform-cxx/README.md b/packages/rs-platform-cxx/README.md new file mode 100644 index 00000000000..7f484ec3897 --- /dev/null +++ b/packages/rs-platform-cxx/README.md @@ -0,0 +1,104 @@ +# dash-platform-cxx + +Dash Platform for C++ applications, as a thin [`cxx`](https://cxx.rs) bridge +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. The SDK +supplies everything else: query construction, transport (TLS to the +evonodes), retries with address banning, proof verification, protocol-version +tracking and the DPNS / DashPay document builders. Dash Core's platform GUI is +the first consumer. + +## What it is + +- `PlatformClient` (`client.rs`): one `Sdk` instance plus the tokio runtime + that drives it and the embedder's freshness state. Every bridge call blocks + the calling thread until the SDK operation completes or `shutdown` + interrupts it; the embedder keeps its own worker thread and callback + marshalling. +- Proved queries (`queries.rs`): identities (by id, by unique public key + hash), identity and identity-contract nonces, DPNS resolve / search / + names-of-identity, DashPay profile and contact requests, contested-name vote + state, and state-transition broadcast. Each goes through the SDK's + `Fetch` / `FetchMany`, so the SDK retains the rich query it built and + verifies the response against it: no wire request is ever reconstructed + from bytes. +- Trust context (`provider.rs`): a `ContextProvider` the SDK reads quorum keys + and the pinned system contracts from. Nothing fetches from a trusted HTTP + service; a proof signed by any quorum the embedder did not push, or by a + quorum type other than the network's Platform type, fails verification. +- Signing (`st.rs`): dpp's own `Signer` trait implemented over a C++ digest + callback (`WalletSigner`, `include/dash/platform/signer.h`), so private keys + never cross the FFI. Document assembly is `dash-platform-queries`' pure + DPNS / DashPay builders, the same functions `dash-sdk` uses. + +`cxx` rather than the workspace's usual cbindgen C ABI because the surface is +dominated by nested byte vectors and fallible calls: cxx gives typed +`rust::Vec` fields and `Result → rust::Error` without hand-written +length/ownership plumbing on either side. + +## Trust boundary + +Every response byte comes from an untrusted node, and the SDK's GroveDB +replay necessarily runs before the quorum signature check (the root hash only +exists after replay). On top of the SDK's own checks (proof replay, quorum +signature, signed-time window, monotonic Platform height, upward-only +protocol-version ratchet), the client refuses a verified response whose +Tenderdash chain id is not the network's, or whose signed core-chain-locked +height trails the embedder's own ChainLock by more than +`MAX_CORE_CHAINLOCK_LAG` core blocks. Responses are size-capped by the SDK's +decoder limit; every fallible bridge entry point runs under `catch_unwind`, +so a panic anywhere in the SDK is reported as a `rust::Error`, never a +process abort (the infallible ones swallow a panic themselves). +The crate must therefore be built with `panic = "unwind"` (the default). + +TLS is verified against the system trust store plus the bundled Mozilla +roots (rustls). Integrity does not rest on it: every query result is bound to +a locally known Platform quorum key before the embedder sees it. + +## Building and installing + +The crate is an ordinary workspace member. Build systems vendor from the +workspace root and build only this package: + +```sh +cargo vendor --locked vendored # offline crate sources +cargo build -p dash-platform-cxx --locked --offline --release +``` + +`build.rs` stages the headers an embedder includes under +`target//include/`: + +``` +include/dash/platform/ffi.h # generated bridge (namespace platform_ffi) +include/dash/platform/signer.h # WalletSigner callback type +include/rust/cxx.h # cxx runtime header ffi.h includes +``` + +Install that `include/` tree and `target//libdash_platform_cxx.a`. +Link with `-lpthread -lm`, plus `-ldl` on Linux and `-framework Security +-framework CoreFoundation` on macOS (rustls reads the system trust store +through Security.framework). `tests/cxx_smoke.cc` is a link-and-run check of +exactly that interface; CI runs it through `scripts/cxx-smoke.sh`. + +## Runtime lifecycle + +1. `new_platform_client()`. +2. `set_context(network, platform_quorum_type, tenderdash_chain_id, + protocol_version, platform_activation_height)` once. +3. On every masternode-list / quorum update: `set_endpoints(...)` (rebuilds + the SDK only when the set changed; the verified-height watermark survives) + and `update_quorum_keys(...)` (replaces the set). +4. On every ChainLock: `set_core_chain_locked_height(...)`. +5. Queries and broadcasts from any thread. +6. `shutdown()` cancels in-flight requests and releases the runtime; blocked + callers return with an interruption error. + +## Tests + +`tests/queries.rs` replays drive-proof-verifier's proof-vector corpus through +`dash-sdk`'s mock transport: only the socket is mocked, the SDK's `FromProof` +path runs the GroveDB replay and the BLS quorum check against the key the +test pushed through the client. `tests/signing.rs` pins every builder byte +for byte against rs-dpp-generated vectors; `tests/decoders.rs` covers the +stored-document decoders. Run with `cargo test -p dash-platform-cxx`. 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..24daa0badd3 --- /dev/null +++ b/packages/rs-platform-cxx/include/dash/platform/signer.h @@ -0,0 +1,65 @@ +// 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 +#include + +namespace platform_ffi { + +//! Signer handed by reference into the Rust state-transition builders. Rust +//! calls SignDigestForKey with the id of the +//! identity key being signed (or ASSET_LOCK_KEY_ID for the one-time +//! asset-lock key of an identity registration) and the 32-byte double-SHA256 +//! digest of the transition's signable bytes; the callback must return a +//! 65-byte compact recoverable ECDSA signature. Private keys never cross +//! the FFI boundary. +class WalletSigner +{ +public: + //! Reserved key id used for asset-lock proof signing (u32::MAX). + static constexpr uint32_t ASSET_LOCK_KEY_ID{0xffffffff}; + + using SignFn = std::function& digest, + std::vector& sig_out)>; + + explicit WalletSigner(SignFn sign_fn) : m_sign_fn(std::move(sign_fn)) {} + + bool SignDigestForKey(uint32_t key_id, rust::Slice digest, + rust::Vec& sig_out) const + { + if (!m_sign_fn || digest.size() != 32) return false; + std::array digest_array; + std::copy(digest.begin(), digest.end(), digest_array.begin()); + std::vector signature; + // This is called from Rust frames; a C++ exception must not unwind + // through them (unsupported by cxx), so a throwing signer reads as a + // signing refusal instead. + try { + if (!m_sign_fn(key_id, digest_array, signature)) return false; + } catch (...) { + return false; + } + sig_out.clear(); + sig_out.reserve(signature.size()); + std::copy(signature.begin(), signature.end(), std::back_inserter(sig_out)); + return true; + } + +private: + SignFn m_sign_fn; +}; + +} // 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/client.rs b/packages/rs-platform-cxx/src/client.rs new file mode 100644 index 00000000000..917d87345ca --- /dev/null +++ b/packages/rs-platform-cxx/src/client.rs @@ -0,0 +1,310 @@ +// 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`: a tokio runtime this crate owns, an +//! `Sdk` built against the evonode endpoints the embedder discovered, and +//! the freshness policy applied to every verified response on top of the +//! SDK's own (signed time window plus monotonic height). +//! +//! Endpoints come from the embedder's deterministic masternode list, quorum +//! keys from its LLMQ store and signing from its wallet; the SDK supplies +//! query construction, transport (TLS to the evonodes), retries with +//! address banning, proof verification and protocol-version tracking. + +use std::future::Future; +use std::str::FromStr; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use dash_sdk::platform::proto::ResponseMetadata; +use dash_sdk::sdk::AddressList; +use dash_sdk::{RequestSettings, Sdk, SdkBuilder}; +use platform_version::version::PlatformVersion; + +use crate::provider::{Context, LocalContextProvider}; +use crate::types::Meta; + +/// Per-call deadline; the SDK retries across endpoints inside it. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +/// Time budget to open a TLS connection to one evonode. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// Attempts per logical request before it fails; each attempt may hit a +/// different endpoint as the SDK bans failing ones. +const RETRIES: usize = 3; +/// Largest gRPC response the SDK will decode. DAPI's servers cap messages +/// well below this; a larger blob is not a Platform response. +const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024; +/// A verified response whose signed time is further than this from the +/// local clock comes from a stale (or replayed) block. +const TIME_TOLERANCE: Duration = Duration::from_secs(10 * 60); +/// Platform blocks a response may trail the highest verified height before +/// it is treated as stale. +const HEIGHT_TOLERANCE: u64 = 3; +/// Coarse staleness bound (in core blocks) between a proof's signed +/// core-chain-locked height and 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 replay is bounded. +pub const MAX_CORE_CHAINLOCK_LAG: u64 = 288; +/// Worker stack size for the runtime threads that run proof verification; +/// GroveDB replay recurses deeper than a default thread stack allows. +const WORKER_STACK_SIZE: usize = 16 * 1024 * 1024; + +struct Inner { + runtime: tokio::runtime::Runtime, + sdk: Sdk, + endpoints: Vec, +} + +/// Freshness state the embedder feeds and every verified response is +/// checked against. +#[derive(Default)] +struct Freshness { + /// Best locally verified core ChainLock height; 0 = unknown. + local_core_chainlock_height: u32, + /// Highest verified Platform height seen; carried across SDK rebuilds + /// so a new endpoint set cannot serve an older state than already seen. + last_seen_height: u64, +} + +/// One SDK instance plus the context it runs against. +pub struct Client { + provider: Arc, + inner: Mutex>, + freshness: Mutex, +} + +impl Default for Client { + fn default() -> Self { + Self::new() + } +} + +impl Client { + pub fn new() -> Self { + Client { + provider: Arc::new(LocalContextProvider::default()), + inner: Mutex::new(None), + freshness: Mutex::new(Freshness::default()), + } + } + + pub fn provider(&self) -> &Arc { + &self.provider + } + + /// Installs the network context. An SDK built for a previous context is + /// discarded; the next `set_endpoints` builds one for the new context. + pub fn set_context(&self, context: Context) -> Result<(), String> { + self.provider.set_context(context)?; + // The watermarks belonged to the previous network. + *lock(&self.freshness) = Freshness::default(); + self.shutdown(); + Ok(()) + } + + /// Replaces the evonode endpoint set (`https://host:port` URIs) and + /// rebuilds the SDK against it. The SDK's address list is fixed at + /// construction, so a changed masternode list means a new instance; the + /// verified-height watermark survives the rebuild. + pub fn set_endpoints(&self, endpoints: Vec) -> Result<(), String> { + let mut inner = lock(&self.inner); + if let Some(current) = inner.as_ref() { + if current.endpoints == endpoints { + return Ok(()); + } + } + if endpoints.is_empty() { + return Err("no evonode endpoints".to_string()); + } + let context = self.provider.context()?; + let addresses = AddressList::from_str(&endpoints.join(",")) + .map_err(|e| format!("bad evonode endpoint: {e}"))?; + let initial_version = PlatformVersion::get(context.protocol_version) + .map_err(|e| format!("protocol version {}: {e}", context.protocol_version))?; + let sdk = SdkBuilder::new(addresses) + .with_network(context.network) + .with_proofs(true) + .with_context_provider(Arc::clone(&self.provider)) + .with_initial_version(initial_version) + .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(Some(HEIGHT_TOLERANCE)) + .with_trusted_initial_height(self.last_seen_height()) + .build() + .map_err(|e| format!("unable to build the Platform SDK: {e}"))?; + let previous = inner.replace(Inner { + runtime: build_runtime()?, + sdk, + endpoints, + }); + drop(inner); + if let Some(previous) = previous { + stop(previous); + } + Ok(()) + } + + /// Installs a ready-made SDK (tests use a mock one). Replaces any + /// previous instance. Only available with the `mocks` feature: a mock + /// SDK can answer from expectations without proof verification. + #[cfg(feature = "mocks")] + pub fn set_sdk(&self, sdk: Sdk) -> Result<(), String> { + let previous = lock(&self.inner).replace(Inner { + runtime: build_runtime()?, + sdk, + endpoints: Vec::new(), + }); + if let Some(previous) = previous { + stop(previous); + } + Ok(()) + } + + /// Highest Platform height verified so far (0 = none yet). Seeds the + /// SDK's monotonic height check when the SDK is rebuilt. + pub fn last_seen_height(&self) -> u64 { + lock(&self.freshness).last_seen_height + } + + /// Updates the embedder's best ChainLock height, the anchor of the + /// core-height staleness floor. Monotonic. + pub fn set_core_chain_locked_height(&self, height: u32) { + let mut freshness = lock(&self.freshness); + if height > freshness.local_core_chainlock_height { + freshness.local_core_chainlock_height = height; + } + } + + /// The protocol version verified responses have shown the network to be + /// running (at least the context's floor). + pub fn platform_version(&self) -> Result<&'static PlatformVersion, String> { + if let Some(inner) = lock(&self.inner).as_ref() { + return Ok(inner.sdk.version()); + } + let protocol_version = self.provider.context()?.protocol_version; + PlatformVersion::get(protocol_version) + .map_err(|e| format!("protocol version {protocol_version}: {e}")) + } + + /// Runs one SDK operation to completion on the runtime's worker threads + /// (large stacks: proof replay recurses), blocking the calling thread. + /// The SDK is cloned out of the 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, + { + let (handle, sdk) = { + let inner = lock(&self.inner); + let inner = inner + .as_ref() + .ok_or("platform client has no endpoints (set_endpoints)")?; + (inner.runtime.handle().clone(), inner.sdk.clone()) + }; + let task = handle.spawn(op(sdk)); + match handle.block_on(task) { + Ok(Ok(value)) => Ok(value), + Ok(Err(e)) => Err(e.to_string()), + Err(join) if join.is_cancelled() => Err("platform request interrupted".to_string()), + Err(join) => Err(format!("platform request panicked: {join}")), + } + } + + /// Runs one metadata-returning SDK operation and applies the freshness + /// policy to its authenticated metadata. Every proved query goes through + /// here, so the chain-id check and the ChainLock floor cannot be skipped; + /// [`Self::run`] stays public for the raw broadcast path and Rust callers. + pub fn fetch(&self, op: F) -> Result<(T, Meta), String> + where + F: FnOnce(Sdk) -> Fut, + Fut: Future> + Send + 'static, + T: Send + 'static, + { + let (value, metadata) = self.run(op)?; + Ok((value, self.accept(&metadata)?)) + } + + /// Post-verification checks on the signature-authenticated metadata of a + /// verified response: the Tenderdash chain id must be this network's + /// (the quorum signature covers it, so a cross-chain replay cannot forge + /// it), and the signed core-chain-locked height must not trail the + /// embedder's own ChainLock by more than [`MAX_CORE_CHAINLOCK_LAG`]. The + /// SDK has already applied its signed-time window and monotonic Platform + /// height check before the response reaches here. Until the embedder has + /// pushed a ChainLock height the floor is inactive and the signed-time + /// window is the only replay bound; push the height before querying. + pub fn accept(&self, metadata: &ResponseMetadata) -> Result { + let context = self.provider.context()?; + if metadata.chain_id != context.tenderdash_chain_id { + return Err(format!( + "response signed for tenderdash chain {:?}, expected {:?}", + metadata.chain_id, context.tenderdash_chain_id + )); + } + let mut freshness = lock(&self.freshness); + let local = u64::from(freshness.local_core_chainlock_height); + let signed = u64::from(metadata.core_chain_locked_height); + if local > 0 && signed + MAX_CORE_CHAINLOCK_LAG < local { + return Err(format!( + "stale platform proof: signed core chainlock height {signed} trails the local \ + ChainLock height {local} by more than {MAX_CORE_CHAINLOCK_LAG} blocks" + )); + } + if metadata.height > freshness.last_seen_height { + freshness.last_seen_height = metadata.height; + } + Ok(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(), + }) + } + + /// Cancels in-flight requests and releases the runtime. Callers blocked + /// in [`Self::run`] return with an interruption error. Idempotent. + pub fn shutdown(&self) { + // Take the instance out from under the lock first: an `if let` on the + // guard would hold it through the blocking runtime teardown and stall + // every concurrent `run`. + let inner = lock(&self.inner).take(); + if let Some(inner) = inner { + stop(inner); + } + } +} + +fn build_runtime() -> Result { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .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}")) +} + +fn stop(inner: Inner) { + inner.sdk.shutdown(); + // The connection pool is dropped inside the runtime context (tonic's + // channels expect a reactor on drop) and before the runtime itself. + { + let _entered = inner.runtime.enter(); + drop(inner.sdk); + } + inner.runtime.shutdown_timeout(Duration::from_secs(2)); +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} diff --git a/packages/rs-platform-cxx/src/decode.rs b/packages/rs-platform-cxx/src/decode.rs new file mode 100644 index 00000000000..f8a3b8f0b58 --- /dev/null +++ b/packages/rs-platform-cxx/src/decode.rs @@ -0,0 +1,219 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! Flattening of dpp identities and DPNS / DashPay documents into the plain +//! types the bridge exposes. Documents arrive here already verified by the +//! SDK; the byte decoders exist for embedders that persisted serialized +//! documents and read them back. + +use std::collections::BTreeMap; + +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{Identity, IdentityPublicKey}; +use dpp::platform_value::Value; +use dpp::serialization::PlatformDeserializable; +use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use platform_version::version::PlatformVersion; + +use crate::types::{ContactRequest, DpnsName, IdentityInfo, KeyInfo, Profile}; + +/// Largest input the byte decoders accept. Anything bigger is not a +/// Platform object and would only make the decoders allocate. +pub const MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024; + +fn check_size(bytes: &[u8], what: &str) -> Result<(), String> { + if bytes.len() > MAX_MESSAGE_BYTES { + return Err(format!( + "{what} is {} bytes, above the {MAX_MESSAGE_BYTES}-byte limit", + bytes.len() + )); + } + Ok(()) +} + +/// Flattens a dpp key into the FFI form. +pub(crate) fn key_info(key: &IdentityPublicKey) -> KeyInfo { + KeyInfo { + 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(), + } +} + +/// Flattens a dpp identity into the FFI form. +pub(crate) fn identity_info(identity: &Identity) -> IdentityInfo { + IdentityInfo { + id: identity.id().to_buffer(), + balance: identity.balance(), + revision: identity.revision(), + keys: identity.public_keys().values().map(key_info).collect(), + } +} + +pub fn decode_identity(bytes: &[u8]) -> Result { + check_size(bytes, "identity")?; + let identity = + Identity::deserialize_from_bytes(bytes).map_err(|e| format!("bad identity: {e}"))?; + Ok(identity_info(&identity)) +} + +pub fn decode_identity_public_key(bytes: &[u8]) -> Result { + check_size(bytes, "identity public key")?; + let key = IdentityPublicKey::deserialize_from_bytes(bytes) + .map_err(|e| format!("bad identity public key: {e}"))?; + Ok(key_info(&key)) +} + +/// Decodes a stored system-contract document under `version` (the protocol +/// version it was proved under). +pub fn decode_document( + bytes: &[u8], + contract: SystemDataContract, + document_type_name: &str, + version: &PlatformVersion, +) -> Result { + check_size(bytes, document_type_name)?; + let contract = load_system_data_contract(contract, version) + .map_err(|e| format!("unable to load system data contract: {e}"))?; + let document_type = contract + .document_type_for_name(document_type_name) + .map_err(|e| format!("unknown document type {document_type_name}: {e}"))?; + Document::from_bytes(bytes, document_type, version) + .map_err(|e| format!("bad {document_type_name} document: {e}")) +} + +fn get_str(properties: &BTreeMap, name: &str) -> String { + match properties.get(name) { + Some(Value::Text(text)) => text.clone(), + _ => String::new(), + } +} + +fn get_bytes(properties: &BTreeMap, name: &str) -> Vec { + match properties.get(name) { + Some(Value::Bytes(bytes)) => bytes.clone(), + Some(Value::Bytes20(bytes)) => bytes.to_vec(), + Some(Value::Bytes32(bytes)) => bytes.to_vec(), + Some(Value::Bytes36(bytes)) => bytes.to_vec(), + Some(Value::Identifier(id)) => id.to_vec(), + _ => Vec::new(), + } +} + +fn get_u32(properties: &BTreeMap, name: &str) -> Result { + let Some(value) = properties.get(name) else { + return Ok(0); + }; + value + .clone() + .into_integer::() + .map_err(|e| format!("property {name} is not a u32: {e}")) +} + +fn get_identifier(value: Option<&Value>) -> Option<[u8; 32]> { + match value { + Some(Value::Identifier(id)) => Some(*id), + Some(Value::Bytes32(bytes)) => Some(*bytes), + Some(Value::Bytes(bytes)) => bytes.as_slice().try_into().ok(), + _ => None, + } +} + +pub fn dpns_domain(document: &Document) -> Result { + let properties = document.properties(); + let identity = properties + .get("records") + .and_then(|records| match records { + Value::Map(map) => map + .iter() + .find(|(key, _)| matches!(key, Value::Text(text) if text == "identity")) + .map(|(_, value)| value), + _ => None, + }) + .and_then(|value| get_identifier(Some(value))) + .ok_or("DPNS domain document has no records.identity")?; + Ok(DpnsName { + label: get_str(properties, "label"), + normalized_label: get_str(properties, "normalizedLabel"), + parent_domain: get_str(properties, "normalizedParentDomainName"), + identity, + document_id: document.id().to_buffer(), + owner_id: document.owner_id().to_buffer(), + }) +} + +pub fn dashpay_profile(document: &Document) -> Result { + let properties = document.properties(); + Ok(Profile { + document_id: document.id().to_buffer(), + owner_id: document.owner_id().to_buffer(), + display_name: get_str(properties, "displayName"), + public_message: get_str(properties, "publicMessage"), + avatar_url: get_str(properties, "avatarUrl"), + avatar_hash: get_bytes(properties, "avatarHash"), + avatar_fingerprint: get_bytes(properties, "avatarFingerprint"), + created_at: document.created_at().unwrap_or(0), + updated_at: document.updated_at().unwrap_or(0), + revision: document.revision().unwrap_or(0), + }) +} + +pub fn contact_request(document: &Document) -> Result { + let properties = document.properties(); + let to_user_id = get_identifier(properties.get("toUserId")) + .ok_or("contact request document has no toUserId")?; + Ok(ContactRequest { + document_id: document.id().to_buffer(), + owner_id: document.owner_id().to_buffer(), + to_user_id, + encrypted_public_key: get_bytes(properties, "encryptedPublicKey"), + sender_key_index: get_u32(properties, "senderKeyIndex")?, + recipient_key_index: get_u32(properties, "recipientKeyIndex")?, + account_reference: get_u32(properties, "accountReference")?, + encrypted_account_label: get_bytes(properties, "encryptedAccountLabel"), + core_height_created_at: document.created_at_core_block_height().unwrap_or(0), + created_at: document.created_at().unwrap_or(0), + }) +} + +pub fn decode_dpns_domain(doc_bytes: &[u8], version: &PlatformVersion) -> Result { + dpns_domain(&decode_document( + doc_bytes, + SystemDataContract::DPNS, + "domain", + version, + )?) +} + +pub fn decode_dashpay_profile( + doc_bytes: &[u8], + version: &PlatformVersion, +) -> Result { + dashpay_profile(&decode_document( + doc_bytes, + SystemDataContract::Dashpay, + "profile", + version, + )?) +} + +pub fn decode_contact_request( + doc_bytes: &[u8], + version: &PlatformVersion, +) -> Result { + contact_request(&decode_document( + doc_bytes, + SystemDataContract::Dashpay, + "contactRequest", + version, + )?) +} diff --git a/packages/rs-platform-cxx/src/lib.rs b/packages/rs-platform-cxx/src/lib.rs new file mode 100644 index 00000000000..3753f4a0691 --- /dev/null +++ b/packages/rs-platform-cxx/src/lib.rs @@ -0,0 +1,1028 @@ +// 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` bridge over `dash-sdk`. +//! +//! - `client`: the SDK instance, its runtime and the freshness policy; +//! - `queries`: the proved queries, through the SDK's `Fetch`/`FetchMany`; +//! - `decode`: flattening of identities and DPNS / DashPay documents; +//! - `st`: state-transition construction with callback-based signing. +//! +//! 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. Signing crosses the FFI +//! as a digest callback (`WalletSigner`, `dash/platform/signer.h`) so private +//! keys never leave the embedder. +//! +//! Every fallible `extern "Rust"` body runs under +//! [`std::panic::catch_unwind`]: cxx turns a `Result::Err` into a C++ +//! `rust::Error`, but a panic that reaches its shim is a deterministic abort +//! of the embedding process. All responses are untrusted DAPI bytes, so a +//! panic anywhere in the SDK is reported as an error instead. The three +//! infallible entry points (`new_platform_client`, `set_core_chain_locked_height`, +//! `shutdown`) touch no untrusted input and swallow a panic themselves. (This +//! only helps under `panic = "unwind"`; the crate must not be built with +//! `panic = "abort"`.) + +pub mod client; +pub mod decode; +pub mod provider; +pub mod queries; +pub mod st; +pub mod types; + +use client::Client; +use types::{BuiltTransition, KeyInfo}; + +#[allow(clippy::too_many_arguments)] +#[cxx::bridge(namespace = "platform_ffi")] +mod ffi { + /// One identity public key. `has_disabled_at == false` means the key is + /// not disabled. + #[derive(Clone)] + struct FfiIdentityKey { + id: u32, + purpose: u8, + security_level: u8, + key_type: u8, + read_only: bool, + data: Vec, + has_disabled_at: bool, + disabled_at: u64, + } + + /// One contender of a contested resource: identity id and its vote + /// tally (when requested/available). + struct FfiContender { + identity: Vec, + has_votes: bool, + votes: u32, + } + + /// Decoded identity. + struct FfiIdentity { + id: Vec, + balance: u64, + revision: u64, + keys: Vec, + } + + /// Decoded DPNS domain document. + #[derive(Clone)] + struct FfiDpnsName { + label: String, + normalized_label: String, + parent_domain: String, + identity: Vec, + document_id: Vec, + owner_id: Vec, + } + + /// Decoded DashPay profile document. Empty vectors/strings and zero + /// timestamps mean the field is absent. + struct FfiProfile { + document_id: Vec, + owner_id: Vec, + display_name: String, + public_message: String, + avatar_url: String, + avatar_hash: Vec, + avatar_fingerprint: Vec, + created_at: u64, + updated_at: u64, + revision: u64, + } + + /// Decoded DashPay contactRequest document. + #[derive(Clone)] + struct FfiContactRequest { + owner_id: Vec, + to_user_id: Vec, + encrypted_public_key: Vec, + sender_key_index: u32, + recipient_key_index: u32, + account_reference: u32, + encrypted_account_label: Vec, + core_height_created_at: u32, + created_at: u64, + document_id: Vec, + } + + /// A quorum BLS public key pushed from the node's LLMQ store. + /// `quorum_hash` (32 bytes) is in the byte order DAPI proofs carry it + /// (display order); `pubkey` is the 48-byte basic-scheme public key. + #[derive(Clone)] + struct FfiQuorumKey { + quorum_hash: Vec, + pubkey: Vec, + } + + /// Authenticated ResponseMetadata fields of a verified response (the + /// quorum signature covers them via the StateId sign bytes). + #[derive(Clone)] + struct FfiMeta { + height: u64, + core_chain_locked_height: u32, + time_ms: u64, + protocol_version: u32, + chain_id: String, + } + + /// Verified optional u64 (nonce); `present == false` means proven + /// absent. + struct FfiVerifiedU64 { + present: bool, + value: u64, + meta: FfiMeta, + } + + /// Verified optional identity; `present == false` means proven absent. + struct FfiVerifiedIdentity { + present: bool, + identity: FfiIdentity, + meta: FfiMeta, + } + + /// Verified optional DPNS name; `present == false` means proven absent. + struct FfiVerifiedDpnsName { + present: bool, + name: FfiDpnsName, + meta: FfiMeta, + } + + /// Verified DPNS name list (empty means proven no matches). + struct FfiVerifiedDpnsNames { + names: Vec, + meta: FfiMeta, + } + + /// Verified optional DashPay profile; `present == false` means proven + /// absent. + struct FfiVerifiedProfile { + present: bool, + profile: FfiProfile, + meta: FfiMeta, + } + + /// Verified contact request list (empty means proven no matches). + struct FfiVerifiedContactRequests { + requests: Vec, + meta: FfiMeta, + } + + /// Verified contested-resource vote state. + struct FfiVerifiedContested { + /// False when the contest was cryptographically proven absent. + contest_found: bool, + contenders: Vec, + has_abstain: bool, + abstain_votes: u32, + has_lock: bool, + lock_votes: u32, + /// True once the poll finished (awarded or locked). + finished: bool, + locked: bool, + has_winner: bool, + winner: Vec, + finished_at_time_ms: u64, + meta: FfiMeta, + } + + /// Outcome of broadcasting a state transition. `accepted == false` + /// carries the node's rejection; that channel is informational (not + /// proof-backed), success is confirmed by a proved re-query. + struct FfiBroadcastResult { + accepted: bool, + error: String, + error_code: u32, + } + + /// A public key to register with a new identity. + struct FfiNewIdentityKey { + id: u32, + purpose: u8, + security_level: u8, + /// Compressed secp256k1 public key (33 bytes). + pubkey: Vec, + } + + /// A built, signed state transition. `hash` is sha256(bytes), the wait + /// handle for waitForStateTransitionResult. + struct FfiBuiltTransition { + bytes: Vec, + hash: Vec, + } + + unsafe extern "C++" { + include!("dash/platform/signer.h"); + + /// Wallet-backed signer. `SignDigestForKey` signs a 32-byte digest + /// with the wallet key identified by `key_id` (`u32::MAX` selects + /// the one-time asset-lock key of an identity registration) and + /// returns a 65-byte compact recoverable ECDSA signature, or false + /// on failure. + type WalletSigner; + fn SignDigestForKey( + self: &WalletSigner, + key_id: u32, + digest: &[u8], + sig_out: &mut Vec, + ) -> bool; + } + + extern "Rust" { + /// One Platform SDK instance: its runtime, endpoint set, trust + /// context and freshness state. Thread-safe; every method may be + /// called from any thread and blocks until its request completes or + /// `shutdown` interrupts it. + type PlatformClient; + + fn new_platform_client() -> Box; + + // --- Node-local context ------------------------------------------ + /// Sets the network ("main"/"test"/"regtest"/"devnet"), the LLMQ + /// type its Platform quorums use (proofs signed by any other type are + /// refused), its Tenderdash chain id (a verified response signed for + /// another chain is refused), the lowest protocol version the network + /// can be running (the SDK ratchets up from it) and the Platform + /// activation core height (0 = unknown). Clears any previously + /// pushed quorum keys and endpoint set. + fn set_context( + self: &PlatformClient, + network_id: &str, + platform_quorum_type: u32, + tenderdash_chain_id: &str, + protocol_version: u32, + platform_activation_height: u32, + ) -> Result<()>; + /// Replaces the evonode endpoint set (`https://host:port` URIs from + /// the deterministic masternode list). Rebuilds the SDK when the set + /// changed. + fn set_endpoints(self: &PlatformClient, endpoints: Vec) -> Result<()>; + /// Replaces the stored Platform quorum keys with `keys`. + fn update_quorum_keys(self: &PlatformClient, keys: Vec) -> Result<()>; + /// Updates the node's best ChainLock height, the anchor of the + /// staleness floor applied to every verified response. + fn set_core_chain_locked_height(self: &PlatformClient, height: u32); + /// Interrupts in-flight requests and releases the runtime. + fn shutdown(self: &PlatformClient); + + // --- Proved queries ----------------------------------------------- + fn get_identity(self: &PlatformClient, id: &[u8]) -> Result; + fn get_identity_by_pubkey_hash( + self: &PlatformClient, + pubkey_hash: &[u8], + ) -> Result; + fn get_identity_nonce(self: &PlatformClient, id: &[u8]) -> Result; + fn get_identity_contract_nonce( + self: &PlatformClient, + id: &[u8], + contract_id: &[u8], + ) -> Result; + fn resolve_name( + self: &PlatformClient, + normalized_label: &str, + ) -> Result; + fn search_names( + self: &PlatformClient, + prefix: &str, + limit: u32, + ) -> Result; + fn names_of_identity( + self: &PlatformClient, + identity: &[u8], + ) -> Result; + fn get_profile(self: &PlatformClient, owner_id: &[u8]) -> Result; + fn get_contact_requests( + self: &PlatformClient, + identity: &[u8], + to_me: bool, + ) -> Result; + fn get_contested_vote_state( + self: &PlatformClient, + normalized_label: &str, + ) -> Result; + + // --- Broadcast --------------------------------------------------- + fn broadcast_state_transition( + self: &PlatformClient, + state_transition: &[u8], + ) -> Result; + + // --- DPP decoders (stored bytes, under the network's version) ---- + fn decode_identity(self: &PlatformClient, bytes: &[u8]) -> Result; + fn decode_identity_public_key( + self: &PlatformClient, + bytes: &[u8], + ) -> Result; + fn decode_dpns_domain(self: &PlatformClient, doc_bytes: &[u8]) -> Result; + fn decode_dashpay_profile(self: &PlatformClient, doc_bytes: &[u8]) -> Result; + fn decode_contact_request( + self: &PlatformClient, + doc_bytes: &[u8], + ) -> Result; + + // --- State transitions ------------------------------------------ + fn st_build_dpns_preorder( + self: &PlatformClient, + identity_id: &[u8], + identity_contract_nonce: u64, + label: &str, + preorder_salt: &[u8], + signature_public_key_id: u32, + key: FfiIdentityKey, + signer: &WalletSigner, + ) -> Result; + fn st_build_dpns_domain( + self: &PlatformClient, + identity_id: &[u8], + identity_contract_nonce: u64, + label: &str, + normalized_label: &str, + parent_domain: &str, + preorder_salt: &[u8], + signature_public_key_id: u32, + key: FfiIdentityKey, + signer: &WalletSigner, + ) -> Result; + fn st_build_profile( + self: &PlatformClient, + identity_id: &[u8], + identity_contract_nonce: u64, + display_name: &str, + public_message: &str, + avatar_url: &str, + avatar_hash: &[u8], + avatar_fingerprint: &[u8], + revision: u64, + has_existing_doc_id: bool, + existing_document_id: &[u8], + entropy: &[u8], + signature_public_key_id: u32, + key: FfiIdentityKey, + signer: &WalletSigner, + ) -> Result; + fn st_build_contact_request( + self: &PlatformClient, + identity_id: &[u8], + identity_contract_nonce: u64, + to_user_id: &[u8], + encrypted_public_key: &[u8], + sender_key_index: u32, + recipient_key_index: u32, + account_reference: u32, + encrypted_account_label: &[u8], + entropy: &[u8], + signature_public_key_id: u32, + key: FfiIdentityKey, + signer: &WalletSigner, + ) -> Result; + fn st_build_identity_create( + self: &PlatformClient, + is_instant: bool, + transaction: &[u8], + instant_lock: &[u8], + output_index: u32, + core_chain_locked_height: u32, + out_point: &[u8], + keys: Vec, + signer: &WalletSigner, + ) -> Result; + } +} + +/// The bridge's opaque client type: [`Client`] behind a `Box` the embedder +/// owns. +pub struct PlatformClient(Client); + +impl PlatformClient { + /// The wrapped client, for Rust callers (tests) that configure it + /// directly. + pub fn inner(&self) -> &Client { + &self.0 + } +} + +fn new_platform_client() -> Box { + Box::new(PlatformClient(Client::new())) +} + +/// Runs a bridge body, turning a panic into an `Err` the C++ side receives as +/// a `rust::Error` instead of the process abort cxx would otherwise perform. +fn guarded(what: &str, body: impl FnOnce() -> Result) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)).unwrap_or_else(|payload| { + let message = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or(""); + Err(format!("{what} panicked: {message}")) + }) +} + +// --------------------------------------------------------------------------- +// Conversions between the plain-Rust core types and the flat FFI structs. +// --------------------------------------------------------------------------- + +fn ffi_key(key: &KeyInfo) -> ffi::FfiIdentityKey { + ffi::FfiIdentityKey { + id: key.id, + purpose: key.purpose, + security_level: key.security_level, + key_type: key.key_type, + read_only: key.read_only, + data: key.data.clone(), + has_disabled_at: key.disabled_at.is_some(), + disabled_at: key.disabled_at.unwrap_or(0), + } +} + +fn key_info_from_ffi(key: &ffi::FfiIdentityKey) -> KeyInfo { + KeyInfo { + id: key.id, + purpose: key.purpose, + security_level: key.security_level, + key_type: key.key_type, + read_only: key.read_only, + data: key.data.clone(), + disabled_at: key.has_disabled_at.then_some(key.disabled_at), + } +} + +fn ffi_built(built: BuiltTransition) -> ffi::FfiBuiltTransition { + ffi::FfiBuiltTransition { + bytes: built.bytes, + hash: built.hash.to_vec(), + } +} + +fn ffi_meta(meta: types::Meta) -> ffi::FfiMeta { + ffi::FfiMeta { + height: meta.height, + core_chain_locked_height: meta.core_chain_locked_height, + time_ms: meta.time_ms, + protocol_version: meta.protocol_version, + chain_id: meta.chain_id, + } +} + +fn ffi_verified_u64((value, meta): (Option, types::Meta)) -> ffi::FfiVerifiedU64 { + ffi::FfiVerifiedU64 { + present: value.is_some(), + value: value.unwrap_or(0), + meta: ffi_meta(meta), + } +} + +fn ffi_identity(identity: &types::IdentityInfo) -> ffi::FfiIdentity { + ffi::FfiIdentity { + id: identity.id.to_vec(), + balance: identity.balance, + revision: identity.revision, + keys: identity.keys.iter().map(ffi_key).collect(), + } +} + +fn ffi_verified_identity( + (identity, meta): (Option, types::Meta), +) -> ffi::FfiVerifiedIdentity { + ffi::FfiVerifiedIdentity { + present: identity.is_some(), + identity: ffi_identity(&identity.unwrap_or_default()), + meta: ffi_meta(meta), + } +} + +fn ffi_verified_name( + (name, meta): (Option, types::Meta), +) -> ffi::FfiVerifiedDpnsName { + ffi::FfiVerifiedDpnsName { + present: name.is_some(), + name: ffi_dpns_name(name.unwrap_or_default()), + meta: ffi_meta(meta), + } +} + +fn ffi_verified_profile( + (profile, meta): (Option, types::Meta), +) -> ffi::FfiVerifiedProfile { + ffi::FfiVerifiedProfile { + present: profile.is_some(), + profile: ffi_profile(profile.unwrap_or_default()), + meta: ffi_meta(meta), + } +} + +fn ffi_verified_requests( + (requests, meta): (Vec, types::Meta), +) -> ffi::FfiVerifiedContactRequests { + ffi::FfiVerifiedContactRequests { + requests: requests.into_iter().map(ffi_contact_request).collect(), + meta: ffi_meta(meta), + } +} + +fn ffi_dpns_name(name: types::DpnsName) -> ffi::FfiDpnsName { + ffi::FfiDpnsName { + label: name.label, + normalized_label: name.normalized_label, + parent_domain: name.parent_domain, + identity: name.identity.to_vec(), + document_id: name.document_id.to_vec(), + owner_id: name.owner_id.to_vec(), + } +} + +fn ffi_verified_names( + (names, meta): (Vec, types::Meta), +) -> ffi::FfiVerifiedDpnsNames { + ffi::FfiVerifiedDpnsNames { + names: names.into_iter().map(ffi_dpns_name).collect(), + meta: ffi_meta(meta), + } +} + +fn ffi_profile(profile: types::Profile) -> ffi::FfiProfile { + ffi::FfiProfile { + document_id: profile.document_id.to_vec(), + owner_id: profile.owner_id.to_vec(), + display_name: profile.display_name, + public_message: profile.public_message, + avatar_url: profile.avatar_url, + avatar_hash: profile.avatar_hash, + avatar_fingerprint: profile.avatar_fingerprint, + created_at: profile.created_at, + updated_at: profile.updated_at, + revision: profile.revision, + } +} + +fn ffi_contact_request(request: types::ContactRequest) -> ffi::FfiContactRequest { + ffi::FfiContactRequest { + owner_id: request.owner_id.to_vec(), + to_user_id: request.to_user_id.to_vec(), + encrypted_public_key: request.encrypted_public_key, + sender_key_index: request.sender_key_index, + recipient_key_index: request.recipient_key_index, + account_reference: request.account_reference, + encrypted_account_label: request.encrypted_account_label, + core_height_created_at: request.core_height_created_at, + created_at: request.created_at, + document_id: request.document_id.to_vec(), + } +} + +fn ffi_contested( + (state, meta): (types::ContestedVoteState, types::Meta), +) -> ffi::FfiVerifiedContested { + ffi::FfiVerifiedContested { + contest_found: state.contest_found, + contenders: state + .contenders + .iter() + .map(|(identity, votes)| ffi::FfiContender { + identity: identity.to_vec(), + has_votes: votes.is_some(), + votes: votes.unwrap_or(0), + }) + .collect(), + has_abstain: state.abstain_votes.is_some(), + abstain_votes: state.abstain_votes.unwrap_or(0), + has_lock: state.lock_votes.is_some(), + lock_votes: state.lock_votes.unwrap_or(0), + finished: state.finished, + locked: state.locked, + has_winner: state.winner.is_some(), + winner: state.winner.map(|id| id.to_vec()).unwrap_or_default(), + finished_at_time_ms: state.finished_at_time_ms, + meta: ffi_meta(meta), + } +} + +/// Shareable handle to the C++ signer. The bridge functions run the async +/// dpp builders to completion on the calling thread with a local executor, +/// so the signer is never actually accessed from another thread; the +/// `Send + Sync` assertion only satisfies dpp's `Signer: Send + Sync` +/// bound. +struct SignerHandle<'a>(&'a ffi::WalletSigner); +unsafe impl Send for SignerHandle<'_> {} +unsafe impl Sync for SignerHandle<'_> {} + +impl SignerHandle<'_> { + fn sign(&self, key_id: u32, digest: [u8; 32]) -> Option> { + let mut signature = Vec::new(); + self.0 + .SignDigestForKey(key_id, &digest, &mut signature) + .then_some(signature) + } +} + +/// The transition's `signature_public_key_id` is set by dpp from the key +/// itself; a caller passing a different id has a bookkeeping bug worth +/// reporting rather than silently signing with the key it did pass. +fn check_key_id(signature_public_key_id: u32, key: &ffi::FfiIdentityKey) -> Result<(), String> { + if signature_public_key_id != key.id { + return Err(format!( + "signature public key id {signature_public_key_id} does not match key id {}", + key.id + )); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Bridge implementations. +// --------------------------------------------------------------------------- + +impl PlatformClient { + /// Shared preamble of the keyed state-transition builders: the key-id + /// consistency check, the signer handle and the network's platform + /// version, all under the panic guard. + fn with_signer( + &self, + what: &str, + signature_public_key_id: u32, + key: ffi::FfiIdentityKey, + signer: &ffi::WalletSigner, + build: impl FnOnce( + &'static platform_version::version::PlatformVersion, + &KeyInfo, + st::SignFn<'_>, + ) -> Result, + ) -> Result { + guarded(what, || { + check_key_id(signature_public_key_id, &key)?; + let handle = SignerHandle(signer); + let sign_fn = |key_id: u32, digest: [u8; 32]| handle.sign(key_id, digest); + build( + self.0.platform_version()?, + &key_info_from_ffi(&key), + &sign_fn, + ) + .map(ffi_built) + }) + } +} + +// The builders mirror the bridge signatures, whose argument lists are the +// flattened document fields C++ passes. +#[allow(clippy::too_many_arguments)] +impl PlatformClient { + fn set_context( + &self, + network_id: &str, + platform_quorum_type: u32, + tenderdash_chain_id: &str, + protocol_version: u32, + platform_activation_height: u32, + ) -> Result<(), String> { + guarded("set_context", || { + self.0.set_context(provider::Context { + network: provider::parse_network(network_id)?, + platform_quorum_type, + tenderdash_chain_id: tenderdash_chain_id.to_string(), + protocol_version, + platform_activation_height, + }) + }) + } + + fn set_endpoints(&self, endpoints: Vec) -> Result<(), String> { + guarded("set_endpoints", || self.0.set_endpoints(endpoints)) + } + + fn update_quorum_keys(&self, keys: Vec) -> Result<(), String> { + guarded("update_quorum_keys", || { + let keys = keys + .into_iter() + .map(|key| { + Ok(provider::QuorumKey { + quorum_hash: types::id32(&key.quorum_hash, "quorum hash")?, + public_key: types::fixed(&key.pubkey, "quorum public key")?, + }) + }) + .collect::, String>>()?; + self.0.provider().update_quorum_keys(keys) + }) + } + + fn set_core_chain_locked_height(&self, height: u32) { + self.0.set_core_chain_locked_height(height); + } + + fn shutdown(&self) { + // Third-party drop paths run here (tonic channels, the runtime); a + // panic on the way out must not abort the embedder. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.0.shutdown())); + } + + fn get_identity(&self, id: &[u8]) -> Result { + guarded("get_identity", || { + queries::get_identity(&self.0, id).map(ffi_verified_identity) + }) + } + + fn get_identity_by_pubkey_hash( + &self, + pubkey_hash: &[u8], + ) -> Result { + guarded("get_identity_by_pubkey_hash", || { + queries::get_identity_by_pubkey_hash(&self.0, pubkey_hash).map(ffi_verified_identity) + }) + } + + fn get_identity_nonce(&self, id: &[u8]) -> Result { + guarded("get_identity_nonce", || { + queries::get_identity_nonce(&self.0, id).map(ffi_verified_u64) + }) + } + + fn get_identity_contract_nonce( + &self, + id: &[u8], + contract_id: &[u8], + ) -> Result { + guarded("get_identity_contract_nonce", || { + queries::get_identity_contract_nonce(&self.0, id, contract_id).map(ffi_verified_u64) + }) + } + + fn resolve_name(&self, normalized_label: &str) -> Result { + guarded("resolve_name", || { + queries::resolve_name(&self.0, normalized_label).map(ffi_verified_name) + }) + } + + fn search_names(&self, prefix: &str, limit: u32) -> Result { + guarded("search_names", || { + queries::search_names(&self.0, prefix, limit).map(ffi_verified_names) + }) + } + + fn names_of_identity(&self, identity: &[u8]) -> Result { + guarded("names_of_identity", || { + queries::names_of_identity(&self.0, identity).map(ffi_verified_names) + }) + } + + fn get_profile(&self, owner_id: &[u8]) -> Result { + guarded("get_profile", || { + queries::get_profile(&self.0, owner_id).map(ffi_verified_profile) + }) + } + + fn get_contact_requests( + &self, + identity: &[u8], + to_me: bool, + ) -> Result { + guarded("get_contact_requests", || { + queries::get_contact_requests(&self.0, identity, to_me).map(ffi_verified_requests) + }) + } + + fn get_contested_vote_state( + &self, + normalized_label: &str, + ) -> Result { + guarded("get_contested_vote_state", || { + queries::get_contested_vote_state(&self.0, normalized_label).map(ffi_contested) + }) + } + + fn broadcast_state_transition( + &self, + state_transition: &[u8], + ) -> Result { + guarded("broadcast_state_transition", || { + let outcome = queries::broadcast_state_transition(&self.0, state_transition)?; + Ok(match outcome { + Ok(()) => ffi::FfiBroadcastResult { + accepted: true, + error: String::new(), + error_code: 0, + }, + Err(rejection) => ffi::FfiBroadcastResult { + accepted: false, + error: rejection.message, + error_code: rejection.code, + }, + }) + }) + } + + fn decode_identity(&self, bytes: &[u8]) -> Result { + guarded("decode_identity", || { + decode::decode_identity(bytes).map(|identity| ffi_identity(&identity)) + }) + } + + fn decode_identity_public_key(&self, bytes: &[u8]) -> Result { + guarded("decode_identity_public_key", || { + decode::decode_identity_public_key(bytes).map(|key| ffi_key(&key)) + }) + } + + fn decode_dpns_domain(&self, doc_bytes: &[u8]) -> Result { + guarded("decode_dpns_domain", || { + decode::decode_dpns_domain(doc_bytes, self.0.platform_version()?).map(ffi_dpns_name) + }) + } + + fn decode_dashpay_profile(&self, doc_bytes: &[u8]) -> Result { + guarded("decode_dashpay_profile", || { + decode::decode_dashpay_profile(doc_bytes, self.0.platform_version()?).map(ffi_profile) + }) + } + + fn decode_contact_request(&self, doc_bytes: &[u8]) -> Result { + guarded("decode_contact_request", || { + decode::decode_contact_request(doc_bytes, self.0.platform_version()?) + .map(ffi_contact_request) + }) + } + + fn st_build_dpns_preorder( + &self, + identity_id: &[u8], + identity_contract_nonce: u64, + label: &str, + preorder_salt: &[u8], + signature_public_key_id: u32, + key: ffi::FfiIdentityKey, + signer: &ffi::WalletSigner, + ) -> Result { + self.with_signer( + "st_build_dpns_preorder", + signature_public_key_id, + key, + signer, + |version, key, sign| { + st::build_dpns_preorder( + version, + identity_id, + identity_contract_nonce, + label, + preorder_salt, + key, + sign, + ) + }, + ) + } + + fn st_build_dpns_domain( + &self, + identity_id: &[u8], + identity_contract_nonce: u64, + label: &str, + normalized_label: &str, + parent_domain: &str, + preorder_salt: &[u8], + signature_public_key_id: u32, + key: ffi::FfiIdentityKey, + signer: &ffi::WalletSigner, + ) -> Result { + self.with_signer( + "st_build_dpns_domain", + signature_public_key_id, + key, + signer, + |version, key, sign| { + st::build_dpns_domain( + version, + identity_id, + identity_contract_nonce, + label, + normalized_label, + parent_domain, + preorder_salt, + key, + sign, + ) + }, + ) + } + + fn st_build_profile( + &self, + identity_id: &[u8], + identity_contract_nonce: u64, + display_name: &str, + public_message: &str, + avatar_url: &str, + avatar_hash: &[u8], + avatar_fingerprint: &[u8], + revision: u64, + has_existing_doc_id: bool, + existing_document_id: &[u8], + entropy: &[u8], + signature_public_key_id: u32, + key: ffi::FfiIdentityKey, + signer: &ffi::WalletSigner, + ) -> Result { + self.with_signer( + "st_build_profile", + signature_public_key_id, + key, + signer, + |version, key, sign| { + st::build_profile( + version, + identity_id, + identity_contract_nonce, + display_name, + public_message, + avatar_url, + avatar_hash, + avatar_fingerprint, + revision, + has_existing_doc_id.then_some(existing_document_id), + entropy, + key, + sign, + ) + }, + ) + } + + fn st_build_contact_request( + &self, + identity_id: &[u8], + identity_contract_nonce: u64, + to_user_id: &[u8], + encrypted_public_key: &[u8], + sender_key_index: u32, + recipient_key_index: u32, + account_reference: u32, + encrypted_account_label: &[u8], + entropy: &[u8], + signature_public_key_id: u32, + key: ffi::FfiIdentityKey, + signer: &ffi::WalletSigner, + ) -> Result { + self.with_signer( + "st_build_contact_request", + signature_public_key_id, + key, + signer, + |version, key, sign| { + st::build_contact_request( + version, + identity_id, + identity_contract_nonce, + to_user_id, + encrypted_public_key, + sender_key_index, + recipient_key_index, + account_reference, + encrypted_account_label, + entropy, + key, + sign, + ) + }, + ) + } + + fn st_build_identity_create( + &self, + is_instant: bool, + transaction: &[u8], + instant_lock: &[u8], + output_index: u32, + core_chain_locked_height: u32, + out_point: &[u8], + keys: Vec, + signer: &ffi::WalletSigner, + ) -> Result { + guarded("st_build_identity_create", || { + let proof = if is_instant { + st::AssetLockProofInput::Instant { + transaction: transaction.to_vec(), + instant_lock: instant_lock.to_vec(), + output_index, + } + } else { + st::AssetLockProofInput::Chain { + core_chain_locked_height, + out_point: types::fixed(out_point, "outpoint")?, + } + }; + let keys: Vec = keys + .into_iter() + .map(|key| st::NewIdentityKey { + id: key.id, + purpose: key.purpose, + security_level: key.security_level, + pubkey: key.pubkey, + }) + .collect(); + let handle = SignerHandle(signer); + let sign_fn = |key_id: u32, digest: [u8; 32]| handle.sign(key_id, digest); + st::build_identity_create(self.0.platform_version()?, proof, &keys, &sign_fn) + .map(ffi_built) + }) + } +} diff --git a/packages/rs-platform-cxx/src/provider.rs b/packages/rs-platform-cxx/src/provider.rs new file mode 100644 index 00000000000..80c602cad1a --- /dev/null +++ b/packages/rs-platform-cxx/src/provider.rs @@ -0,0 +1,209 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! `dash_context_provider::ContextProvider` backed by node-local state. +//! +//! `dash-sdk` resolves everything it needs about the network through this +//! trait: the BLS public key of the quorum that signed a proof, and the data +//! contracts referenced by document queries. The embedder serves both from +//! local knowledge: quorum keys are pushed across the bridge from synced +//! LLMQ data, and the supported document queries use the pinned DPNS and +//! DashPay system contracts compiled into dpp. Nothing here fetches from a +//! trusted HTTP service. + +use std::collections::HashMap; +use std::sync::{Arc, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use dash_context_provider::{ContextProvider, ContextProviderError}; +use dpp::dashcore::Network; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::TokenConfiguration; +use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; +use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use platform_version::version::PlatformVersion; + +/// A quorum public key pushed from the node's LLMQ store. `quorum_hash` is +/// in the byte order DAPI proofs carry it (display order: the hex `quorum +/// info` prints, the reverse of the embedding application's internal +/// uint256 byte order). +pub struct QuorumKey { + pub quorum_hash: [u8; 32], + pub public_key: [u8; 48], +} + +/// The network context the embedder installs before anything else. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Context { + pub network: Network, + /// The LLMQ type Platform quorums use on this network. A proof naming any + /// other quorum type is rejected before its key is looked up, so the set + /// of keys the embedder pushes for other purposes can never sign + /// Platform state. + pub platform_quorum_type: u32, + /// Tenderdash chain id of the network. It enters the quorum signature + /// preimage, so a verified response carrying another id is a signed + /// response from another chain. + pub tenderdash_chain_id: String, + /// Lowest protocol version this network can be running; the SDK ratchets + /// upward from it as verified responses report newer versions. + pub protocol_version: u32, + /// Core height at which Platform activated (mn_rr). Embedders that do not + /// use queries requiring this value may set it to 0. + pub platform_activation_height: CoreBlockHeight, +} + +#[derive(Default)] +struct State { + context: Option, + /// quorum_hash (proof byte order) -> BLS public key, for the Platform + /// quorum type only. + quorum_keys: HashMap<[u8; 32], [u8; 48]>, + /// Lazily loaded pinned system contracts (DPNS, DashPay), keyed by id and + /// the protocol version they were loaded for. + contracts: HashMap<(Identifier, u32), Arc>, +} + +/// Node-local context shared between the SDK (through `ContextProvider`) +/// and the embedder, which updates it as its chain state advances. +#[derive(Default)] +pub struct LocalContextProvider { + state: RwLock, +} + +// A poisoned lock means a bridge call panicked while holding it. The state is +// plain data (no invariants span a write), so keep serving it rather than +// turning every later verification into an error. +fn read(state: &RwLock) -> RwLockReadGuard<'_, State> { + state.read().unwrap_or_else(PoisonError::into_inner) +} + +fn write(state: &RwLock) -> RwLockWriteGuard<'_, State> { + state.write().unwrap_or_else(PoisonError::into_inner) +} + +/// Parses a Dash network id ("main", "test", "regtest", "devnet") into +/// the dashcore `Network` the SDK expects. +pub fn parse_network(network_id: &str) -> Result { + match network_id { + "main" => Ok(Network::Mainnet), + "test" => Ok(Network::Testnet), + "regtest" => Ok(Network::Regtest), + "devnet" => Ok(Network::Devnet), + other => Err(format!("unknown network id {other:?}")), + } +} + +impl LocalContextProvider { + /// Installs the network context. Replaces any previous context and drops + /// the stored quorum keys, which belonged to it. + pub fn set_context(&self, context: Context) -> Result<(), String> { + PlatformVersion::get(context.protocol_version).map_err(|e| { + format!( + "protocol version {} is unknown to this build: {e}", + context.protocol_version + ) + })?; + let mut state = write(&self.state); + state.context = Some(context); + state.quorum_keys.clear(); + Ok(()) + } + + /// The context set via [`Self::set_context`]. + pub fn context(&self) -> Result { + read(&self.state) + .context + .clone() + .ok_or_else(|| "platform bridge context not initialized (set_context)".to_string()) + } + + /// Replaces the stored Platform quorum keys with `keys`. The embedder + /// pushes the full active Platform-LLMQ set on every masternode-list / + /// quorum update, so replacement (not merge) keeps rotated-out quorums + /// from verifying new proofs forever. + pub fn update_quorum_keys(&self, keys: Vec) -> Result<(), String> { + let mut state = write(&self.state); + if state.context.is_none() { + return Err("platform bridge context not initialized (set_context)".to_string()); + } + state.quorum_keys = keys + .into_iter() + .map(|key| (key.quorum_hash, key.public_key)) + .collect(); + Ok(()) + } +} + +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(contract) = read(&self.state).contracts.get(&cache_key) { + return Ok(Some(Arc::clone(contract))); + } + for system_contract in [SystemDataContract::DPNS, SystemDataContract::Dashpay] { + let contract = load_system_data_contract(system_contract, platform_version) + .map_err(|e| ContextProviderError::DataContractFailure(e.to_string()))?; + if contract.id() == *id { + let contract = Arc::new(contract); + write(&self.state) + .contracts + .insert(cache_key, Arc::clone(&contract)); + return Ok(Some(contract)); + } + } + Ok(None) + } + + 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> { + let state = read(&self.state); + let context = state + .context + .as_ref() + .ok_or_else(|| ContextProviderError::Config("set_context not called".to_string()))?; + // The response names the quorum type; only the network's Platform + // type may sign Platform state. + if quorum_type != context.platform_quorum_type { + return Err(ContextProviderError::InvalidQuorum(format!( + "proof signed by quorum type {quorum_type}; Platform quorums on this network are \ + type {}", + context.platform_quorum_type + ))); + } + // The locally synced LLMQ store only tracks currently valid quorums, + // so the requested core height adds nothing to the lookup: a proof + // signed by a quorum the node no longer knows fails verification. + state.quorum_keys.get(&quorum_hash).copied().ok_or_else(|| { + ContextProviderError::InvalidQuorum(format!( + "no locally known Platform quorum with hash {}", + hex::encode(quorum_hash) + )) + }) + } + + fn get_platform_activation_height(&self) -> Result { + read(&self.state) + .context + .as_ref() + .map(|context| context.platform_activation_height) + .ok_or_else(|| ContextProviderError::Config("set_context not called".to_string())) + } +} diff --git a/packages/rs-platform-cxx/src/queries.rs b/packages/rs-platform-cxx/src/queries.rs new file mode 100644 index 00000000000..4e796448cf1 --- /dev/null +++ b/packages/rs-platform-cxx/src/queries.rs @@ -0,0 +1,422 @@ +// 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 queries the embedder issues, expressed through `dash-sdk`'s +//! `Fetch` / `FetchMany` and the shared DPNS / DashPay query shapes. Each +//! function runs one SDK operation on [`Client`], then applies the +//! embedder's freshness policy to the verified metadata and flattens the +//! result into the plain types the bridge exposes. +//! +//! Absence is proven, not inferred: an `Ok(None)` / empty vector comes back +//! only after the SDK verified a proof of it. Transport failures, unverifiable +//! responses and stale metadata are errors. + +use std::sync::Arc; + +use dash_sdk::dapi_client::transport::TransportError; +use dash_sdk::dapi_client::{CanRetry, DapiClientError, DapiRequest, RequestSettings}; +use dash_sdk::dpp::consensus::codes::ErrorWithCode; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +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::voting::contender_structs::ContenderWithSerializedDocument; +use dash_sdk::dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo as WinnerInfo; +use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use dash_sdk::dpp::ProtocolError; +use dash_sdk::platform::types::identity::PublicKeyHash; +use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identity}; +use dash_sdk::query_types::{Contenders, IdentityContractNonceFetcher, IdentityNonceFetcher}; +use dash_sdk::Sdk; +use drive::query::vote_poll_vote_state_query::{ + ContestedDocumentVotePollDriveQuery, ContestedDocumentVotePollDriveQueryResultType, +}; +use drive::query::{OrderClause, WhereClause, WhereOperator}; + +use crate::client::Client; +use crate::decode::{self, identity_info}; +use crate::types::{ + fixed, id32, BroadcastRejection, ContactRequest, ContestedVoteState, DpnsName, IdentityInfo, + Meta, Profile, +}; + +/// Upper bound on documents returned by list queries; mirrors DAPI's +/// default query limit so the locally reconstructed query matches the +/// prover's. +const DOCUMENT_LIMIT: 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"; + +fn system_contract( + client: &Client, + contract: SystemDataContract, +) -> Result, String> { + load_system_data_contract(contract, client.platform_version()?) + .map(Arc::new) + .map_err(|e| format!("unable to load system data contract: {e}")) +} + +// --- identities ---------------------------------------------------------- + +pub fn get_identity(client: &Client, id: &[u8]) -> Result<(Option, Meta), String> { + let id = Identifier::from(id32(id, "identity id")?); + let (identity, meta) = client.fetch(move |sdk: Sdk| async move { + Identity::fetch_with_metadata(&sdk, id, None).await + })?; + Ok((identity.as_ref().map(identity_info), meta)) +} + +pub fn get_identity_by_pubkey_hash( + client: &Client, + hash: &[u8], +) -> Result<(Option, Meta), String> { + let hash: [u8; 20] = fixed(hash, "public key hash")?; + let (identity, meta) = client.fetch(move |sdk: Sdk| async move { + Identity::fetch_with_metadata(&sdk, PublicKeyHash(hash), None).await + })?; + Ok((identity.as_ref().map(identity_info), meta)) +} + +/// Proved identity nonce; `None` = proven absent (the identity has not +/// submitted a transition yet). +pub fn get_identity_nonce(client: &Client, id: &[u8]) -> Result<(Option, Meta), String> { + let id = Identifier::from(id32(id, "identity id")?); + let (nonce, meta) = client.fetch(move |sdk: Sdk| async move { + IdentityNonceFetcher::fetch_with_metadata(&sdk, id, None).await + })?; + Ok((nonce.map(|fetcher| fetcher.0), meta)) +} + +pub fn get_identity_contract_nonce( + client: &Client, + id: &[u8], + contract_id: &[u8], +) -> Result<(Option, Meta), String> { + let id = Identifier::from(id32(id, "identity id")?); + let contract_id = Identifier::from(id32(contract_id, "contract id")?); + let (nonce, meta) = client.fetch(move |sdk: Sdk| async move { + IdentityContractNonceFetcher::fetch_with_metadata(&sdk, (id, contract_id), None).await + })?; + Ok((nonce.map(|fetcher| fetcher.0), meta)) +} + +// --- documents ----------------------------------------------------------- + +fn fetch_documents(client: &Client, query: DocumentQuery) -> Result<(Vec, Meta), String> { + let (documents, meta) = client.fetch(move |sdk: Sdk| async move { + Document::fetch_many_with_metadata(&sdk, query, None).await + })?; + // A proven absence comes back as entries whose document is `None`. + let documents = documents + .into_iter() + .filter_map(|(_, document)| document) + .collect(); + Ok((documents, meta)) +} + +fn document_query( + client: &Client, + contract: SystemDataContract, + document_type: &str, +) -> Result { + DocumentQuery::new(system_contract(client, contract)?, document_type) + .map_err(|e| format!("{document_type} query: {e}")) +} + +/// DPNS domain query scoped to the "dash" parent domain, i.e. the +/// (normalizedParentDomainName, normalizedLabel) index. `names_of_identity` +/// must not use it: that one goes through the records.identity index. +fn dash_tld_query(client: &Client) -> Result { + Ok( + document_query(client, SystemDataContract::DPNS, "domain")?.with_where(equals( + "normalizedParentDomainName", + Value::Text(DPNS_PARENT_DOMAIN.to_string()), + )), + ) +} + +// The SDK's own resolve/search/names-of-identity helpers build the same +// queries but return no ResponseMetadata, which `Client::fetch`'s chain-id +// and ChainLock checks need; hence the local shapes. +fn fetch_names(client: &Client, query: DocumentQuery) -> Result<(Vec, Meta), String> { + let (documents, meta) = fetch_documents(client, query)?; + let names = documents + .iter() + .map(decode::dpns_domain) + .collect::, _>>()?; + Ok((names, meta)) +} + +fn equals(field: &str, value: Value) -> WhereClause { + WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value, + } +} + +/// Resolves a normalized DPNS label under the "dash" parent; `None` = +/// proven unregistered. +pub fn resolve_name( + client: &Client, + normalized_label: &str, +) -> Result<(Option, Meta), String> { + let query = dash_tld_query(client)? + .with_where(equals( + "normalizedLabel", + Value::Text(convert_to_homograph_safe_chars(normalized_label)), + )) + .with_limit(1); + let (names, meta) = fetch_names(client, query)?; + Ok((names.into_iter().next(), meta)) +} + +/// DPNS names whose normalized label starts with `prefix`, ascending. +pub fn search_names( + client: &Client, + prefix: &str, + limit: u32, +) -> Result<(Vec, Meta), String> { + let query = dash_tld_query(client)? + .with_where(WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::StartsWith, + value: Value::Text(convert_to_homograph_safe_chars(prefix)), + }) + .with_order_by(OrderClause { + field: "normalizedLabel".to_string(), + ascending: true, + }) + .with_limit(limit.clamp(1, DOCUMENT_LIMIT)); + fetch_names(client, query) +} + +/// DPNS names whose `records.identity` points at `identity`. +pub fn names_of_identity( + client: &Client, + identity: &[u8], +) -> Result<(Vec, Meta), String> { + let identity = id32(identity, "identity id")?; + let query = document_query(client, SystemDataContract::DPNS, "domain")? + .with_where(equals("records.identity", Value::Identifier(identity))) + .with_limit(DOCUMENT_LIMIT); + fetch_names(client, query) +} + +/// The DashPay profile owned by `owner`; `None` = proven absent. +pub fn get_profile(client: &Client, owner: &[u8]) -> Result<(Option, Meta), String> { + let owner = id32(owner, "owner id")?; + let query = document_query(client, SystemDataContract::Dashpay, "profile")? + .with_where(equals("$ownerId", Value::Identifier(owner))) + .with_limit(1); + let (documents, meta) = fetch_documents(client, query)?; + let profile = documents.first().map(decode::dashpay_profile).transpose()?; + Ok((profile, meta)) +} + +/// Contact requests sent to (`to_me`) or by `identity`, oldest first. +pub fn get_contact_requests( + client: &Client, + identity: &[u8], + to_me: bool, +) -> Result<(Vec, Meta), String> { + let identity = id32(identity, "identity id")?; + let query = document_query(client, SystemDataContract::Dashpay, "contactRequest")? + .with_where(equals( + if to_me { "toUserId" } else { "$ownerId" }, + Value::Identifier(identity), + )) + // A bare secondary-index equality without an order-by is proven + // absent by Drive; ordering by $createdAt pins the contract's + // (field, $createdAt) index. + .with_order_by(OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }) + .with_limit(DOCUMENT_LIMIT); + let (documents, meta) = fetch_documents(client, query)?; + let requests = documents + .iter() + .map(decode::contact_request) + .collect::, _>>()?; + Ok((requests, meta)) +} + +// --- contested names ----------------------------------------------------- + +/// Vote state of the contested DPNS name `normalized_label` (VoteTally with +/// locked and abstaining tallies). `contest_found == false` means the +/// contest was proven absent. +pub fn get_contested_vote_state( + client: &Client, + normalized_label: &str, +) -> Result<(ContestedVoteState, Meta), String> { + let contract = system_contract(client, SystemDataContract::DPNS)?; + let query = ContestedDocumentVotePollDriveQuery { + vote_poll: ContestedDocumentResourceVotePoll { + contract_id: contract.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, + }; + let (contenders, meta): (Contenders, _) = client.fetch(move |sdk: Sdk| async move { + ContenderWithSerializedDocument::fetch_many_with_metadata(&sdk, query, None).await + })?; + Ok((contested_state(contenders), meta)) +} + +/// `FetchMany` folds a proven-absent contest into `Contenders::default()`; +/// a real contest always carries at least its tallies, so an all-empty value +/// reads as absent. +fn contested_state(contenders: Contenders) -> ContestedVoteState { + let mut state = ContestedVoteState::default(); + let absent = contenders.contenders.is_empty() + && contenders.winner.is_none() + && contenders.abstain_vote_tally.is_none() + && contenders.lock_vote_tally.is_none(); + if absent { + return state; + } + state.contest_found = true; + state.contenders = contenders + .contenders + .iter() + .map(|(id, contender)| (id.to_buffer(), contender.vote_tally())) + .collect(); + state.abstain_votes = contenders.abstain_vote_tally; + state.lock_votes = contenders.lock_vote_tally; + if let Some((winner_info, finalization_block)) = contenders.winner { + state.finished = true; + state.finished_at_time_ms = finalization_block.time_ms; + match winner_info { + WinnerInfo::WonByIdentity(id) => state.winner = Some(id.to_buffer()), + WinnerInfo::Locked => state.locked = true, + WinnerInfo::NoWinner => {} + } + } + state +} + +// --- broadcast ----------------------------------------------------------- + +/// Largest state transition the bridge will submit; 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; + +/// Submits a signed state transition. `Ok(Ok(()))` means a node accepted it +/// into its mempool; `Ok(Err(rejection))` carries the node's (unproven) +/// rejection code and message; `Err` is a transport failure. Success is +/// confirmed by the embedder through a proved re-query. +pub fn broadcast_state_transition( + client: &Client, + state_transition: &[u8], +) -> Result, String> { + if state_transition.len() > MAX_STATE_TRANSITION_BYTES { + return Err(format!( + "state transition is {} bytes, above the {MAX_STATE_TRANSITION_BYTES}-byte limit", + state_transition.len() + )); + } + let request = dash_sdk::platform::proto::BroadcastStateTransitionRequest { + state_transition: state_transition.to_vec(), + }; + // Executed as a raw DAPI request rather than through `dash-sdk`'s + // broadcast helper: that helper needs the deserialized transition (to + // refresh nonces on failure), while the embedder built and signed its + // bytes already. A node that rejects the transition is not a failing + // node, so address banning is off for this request; the SDK's error + // conversion decodes the consensus error DAPI attaches as gRPC metadata, + // and that typed error, not the gRPC status bucket, is the rejection. + let settings = RequestSettings { + ban_failed_address: Some(false), + ..RequestSettings::default() + }; + client.run(move |sdk: Sdk| async move { + match request.execute(&sdk, settings).await { + Ok(_) => Ok(Ok(())), + Err(execution) => match execution.inner { + DapiClientError::Transport(TransportError::Grpc(status)) => { + let grpc_code = status.code() as u32; + let retryable = status.can_retry(); + let error = dash_sdk::Error::from(DapiClientError::Transport( + TransportError::Grpc(status), + )); + match error { + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(consensus)) => { + Ok(Err(BroadcastRejection { + code: consensus.code(), + message: consensus.to_string(), + })) + } + other if !retryable => Ok(Err(BroadcastRejection { + code: grpc_code, + message: other.to_string(), + })), + other => Err(other), + } + } + other => Err(dash_sdk::Error::from(other)), + }, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::block::block_info::BlockInfo; + + #[test] + fn homograph_normalization_is_idempotent() { + // Every query normalizes its label once more; a caller that already + // normalized must get the same index value. + for label in ["Alice", "a11ce", "bOb-LoL", "x0y1z"] { + let once = convert_to_homograph_safe_chars(label); + assert_eq!(convert_to_homograph_safe_chars(&once), once); + } + } + + #[test] + fn empty_contenders_read_as_no_contest() { + assert!(!contested_state(Contenders::default()).contest_found); + } + + #[test] + fn a_finished_poll_with_no_contenders_is_still_a_contest() { + let contenders = Contenders { + winner: Some((WinnerInfo::Locked, BlockInfo::default())), + contenders: Default::default(), + abstain_vote_tally: Some(0), + lock_vote_tally: Some(3), + }; + let state = contested_state(contenders); + assert!(state.contest_found); + assert!(state.finished && state.locked); + assert_eq!(state.lock_votes, Some(3)); + } + + #[test] + fn zero_tallies_are_a_contest_not_absence() { + let contenders = Contenders { + winner: None, + contenders: Default::default(), + abstain_vote_tally: Some(0), + lock_vote_tally: Some(0), + }; + assert!(contested_state(contenders).contest_found); + } +} diff --git a/packages/rs-platform-cxx/src/st.rs b/packages/rs-platform-cxx/src/st.rs new file mode 100644 index 00000000000..8af42da0448 --- /dev/null +++ b/packages/rs-platform-cxx/src/st.rs @@ -0,0 +1,638 @@ +// 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. + +//! DPP state-transition construction and signing for C++ embedders, built on +//! the real rs-dpp builders. +//! +//! Signing is delegated through a callback so private keys stay in the +//! wallet: the callback receives the key id being signed plus the +//! double-SHA256 digest of the transition's signable bytes and must return +//! a 65-byte compact recoverable ECDSA signature - exactly what +//! `dpp::dashcore::signer::sign` would produce from the raw key +//! (`sign(data, key) == sign_hash(sha256d(data), key)`). + +use std::collections::BTreeMap; + +use dash_platform_queries::dashpay::{ + build_contact_request_document, ContactRequestDocumentParams, +}; +use dash_platform_queries::dpns_usernames::{ + build_dpns_domain_document, build_dpns_preorder_document, salted_domain_hash, +}; +use dash_platform_queries::transition::validation::ensure_valid_state_transition_structure; +use dpp::address_funds::AddressWitness; +use dpp::dashcore::consensus::Decodable; +use dpp::dashcore::hashes::{sha256, Hash}; +use dpp::dashcore::{InstantLock, OutPoint, Transaction}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::DataContract; +use dpp::document::{Document, DocumentV0, DocumentV0Getters}; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::signer::Signer; +use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::{BinaryData, Value}; +use dpp::prelude::{AssetLockProof, Identifier}; +use dpp::serialization::{PlatformSerializable, Signable}; +use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::identity_create_transition::v0::IdentityCreateTransitionV0; +use dpp::state_transition::public_key_in_creation::accessors::{ + IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV0Setters, +}; +use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; +use dpp::state_transition::StateTransition; +use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use dpp::util::hash::hash_double; +use dpp::util::strings::convert_to_homograph_safe_chars; +use dpp::ProtocolError; +use platform_version::version::PlatformVersion; + +use crate::types::{id32, BuiltTransition, KeyInfo}; + +/// Signs a 32-byte digest with the wallet key identified by `key_id`, +/// returning a 65-byte compact recoverable ECDSA signature, or `None` on +/// failure (locked wallet, unknown key). `ASSET_LOCK_KEY_ID` selects the +/// one-time asset-lock key of an identity registration. +pub type SignFn<'a> = &'a (dyn Fn(u32, [u8; 32]) -> Option> + Sync); + +/// Pseudo key id routed to the asset-lock one-time key. +pub const ASSET_LOCK_KEY_ID: u32 = u32::MAX; + +const COMPACT_SIG_SIZE: usize = 65; +const COMPRESSED_PUBKEY_SIZE: usize = 33; + +/// dpp async `Signer` backed by the digest callback. The async surface is +/// signature-only: the callback is invoked synchronously and the builders +/// are driven by `futures::executor::block_on` on the calling thread. +struct CallbackSigner<'a> { + sign_fn: SignFn<'a>, +} + +impl std::fmt::Debug for CallbackSigner<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("CallbackSigner") + } +} + +fn sign_digest(sign_fn: SignFn<'_>, key_id: u32, digest: [u8; 32]) -> Result, String> { + let signature = sign_fn(key_id, digest) + .ok_or("signing failed (wallet locked or key unavailable)".to_string())?; + if signature.len() != COMPACT_SIG_SIZE { + return Err(format!( + "unexpected signature size {} (want {COMPACT_SIG_SIZE})", + signature.len() + )); + } + Ok(signature) +} + +#[async_trait::async_trait] +impl Signer for CallbackSigner<'_> { + async fn sign( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + sign_digest(self.sign_fn, key.id(), hash_double(data)) + .map(Into::into) + .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 }) + } + + fn can_sign_with(&self, key: &IdentityPublicKey) -> bool { + is_ecdsa(key.key_type()) + } +} + +/// The callback answers with a compact recoverable secp256k1 signature, so +/// only ECDSA keys can be signed for; dpp checks purpose, security level and +/// disabled state, but not the key type. +fn is_ecdsa(key_type: KeyType) -> bool { + matches!(key_type, KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160) +} + +/// Reconstructs a dpp IdentityPublicKey from the flattened FFI form. +fn identity_key_from_info(info: &KeyInfo) -> Result { + let key_type = KeyType::try_from(info.key_type) + .map_err(|e| format!("bad key type {}: {e}", info.key_type))?; + if !is_ecdsa(key_type) { + return Err(format!( + "key {} is {key_type:?}; the signing callback can only produce ECDSA signatures", + info.id + )); + } + Ok(IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: info.id, + purpose: Purpose::try_from(info.purpose) + .map_err(|e| format!("bad key purpose {}: {e}", info.purpose))?, + security_level: SecurityLevel::try_from(info.security_level) + .map_err(|e| format!("bad key security level {}: {e}", info.security_level))?, + contract_bounds: None, + key_type, + read_only: info.read_only, + data: BinaryData::new(info.data.clone()), + disabled_at: info.disabled_at, + })) +} + +fn load_contract( + contract: SystemDataContract, + version: &PlatformVersion, +) -> Result { + load_system_data_contract(contract, version) + .map_err(|e| format!("unable to load system data contract: {e}")) +} + +/// Serializes a signed transition for broadcast, after the same structure +/// validation `dash-sdk` runs before it broadcasts, so a transition Drive +/// would reject on shape fails here instead of after a nonce bump. +fn built( + state_transition: &StateTransition, + version: &PlatformVersion, +) -> Result { + ensure_valid_state_transition_structure(state_transition, version) + .map_err(|e| format!("state transition failed structure validation: {e}"))?; + let bytes = state_transition + .serialize_to_bytes() + .map_err(|e| format!("unable to serialize state transition: {e}"))?; + let hash = sha256::Hash::hash(&bytes).to_byte_array(); + Ok(BuiltTransition { bytes, hash }) +} + +/// Which batch transition to build from an assembled document. +enum BatchKind { + Create { entropy: [u8; 32] }, + Replace, +} + +/// Builds and signs a single-document batch transition from an assembled +/// document. The properties are sanitized for the document type first, as +/// `dash-sdk`'s put-document path does; for a create, dpp's builder refuses +/// an entropy that does not derive the document id (Drive would reject it +/// after the nonce bump). +#[allow(clippy::too_many_arguments)] +fn build_document_transition( + contract: &DataContract, + document_type_name: &str, + mut document: Document, + identity_contract_nonce: u64, + kind: BatchKind, + key: &KeyInfo, + sign_fn: SignFn<'_>, + version: &PlatformVersion, +) -> Result { + let document_type = contract + .document_type_for_name(document_type_name) + .map_err(|e| format!("unknown document type {document_type_name}: {e}"))?; + document_type.sanitize_document_properties(document.properties_mut()); + let identity_key = identity_key_from_info(key)?; + let signer = CallbackSigner { sign_fn }; + let (state_transition, what) = match kind { + BatchKind::Create { entropy } => ( + futures::executor::block_on( + BatchTransition::new_document_creation_transition_from_document( + document, + document_type, + entropy, + &identity_key, + identity_contract_nonce, + 0, + None, + &signer, + version, + None, + ), + ), + "create", + ), + BatchKind::Replace => ( + futures::executor::block_on( + BatchTransition::new_document_replacement_transition_from_document( + document, + document_type, + &identity_key, + identity_contract_nonce, + 0, + None, + &signer, + version, + None, + ), + ), + "replace", + ), + }; + let state_transition = state_transition + .map_err(|e| format!("unable to build {document_type_name} {what} transition: {e}"))?; + built(&state_transition, version) +} + +/// Builds and signs a single-document create batch transition from a +/// property map (documents without an upstream pure builder). +#[allow(clippy::too_many_arguments)] +fn build_document_create( + contract: SystemDataContract, + document_type_name: &str, + owner_id: [u8; 32], + identity_contract_nonce: u64, + properties: BTreeMap, + entropy: [u8; 32], + key: &KeyInfo, + sign_fn: SignFn<'_>, + version: &PlatformVersion, +) -> Result { + let contract = load_contract(contract, version)?; + let owner_id = Identifier::from(owner_id); + let document_id = + Document::generate_document_id_v0(&contract.id(), &owner_id, document_type_name, &entropy); + let document = Document::V0(DocumentV0 { + id: document_id, + owner_id, + properties, + ..Default::default() + }); + build_document_transition( + &contract, + document_type_name, + document, + identity_contract_nonce, + BatchKind::Create { entropy }, + key, + sign_fn, + version, + ) +} + +/// DPNS preorder create. The document comes from the shared builder in +/// dash-platform-queries; the salted domain hash doubles as the document +/// entropy — it is already blinded and unique per (name, salt), so rebuilds +/// of the same registration stay byte-identical. +pub fn build_dpns_preorder( + version: &PlatformVersion, + identity_id: &[u8], + identity_contract_nonce: u64, + label: &str, + preorder_salt: &[u8], + key: &KeyInfo, + sign_fn: SignFn<'_>, +) -> Result { + let owner = id32(identity_id, "identity id")?; + let salt = id32(preorder_salt, "preorder salt")?; + let entropy = salted_domain_hash(label, salt); + let contract = load_contract(SystemDataContract::DPNS, version)?; + let preorder = + build_dpns_preorder_document(&contract, Identifier::from(owner), label, salt, entropy) + .map_err(|e| format!("unable to build DPNS preorder document: {e}"))?; + build_document_transition( + &contract, + "preorder", + preorder, + identity_contract_nonce, + BatchKind::Create { entropy }, + key, + sign_fn, + version, + ) +} + +/// DPNS domain create. The preorder salt is drawn fresh per registration +/// attempt, making it a suitable deterministic document entropy for the +/// paired domain create. The contested-name vote-resolution prefund is +/// computed by rs-dpp from the contested unique index of the DPNS domain +/// type during DocumentCreateTransition::from_document; no explicit +/// handling needed. +#[allow(clippy::too_many_arguments)] +pub fn build_dpns_domain( + version: &PlatformVersion, + identity_id: &[u8], + identity_contract_nonce: u64, + label: &str, + normalized_label: &str, + parent_domain: &str, + preorder_salt: &[u8], + key: &KeyInfo, + sign_fn: SignFn<'_>, +) -> Result { + if convert_to_homograph_safe_chars(label) != normalized_label { + return Err("normalized label does not match label".to_string()); + } + // The shared builder registers under the "dash" TLD; that is the only + // parent domain that exists. + if parent_domain != "dash" { + return Err(format!("unsupported parent domain {parent_domain:?}")); + } + let owner = id32(identity_id, "identity id")?; + let salt = id32(preorder_salt, "preorder salt")?; + let contract = load_contract(SystemDataContract::DPNS, version)?; + let domain = build_dpns_domain_document(&contract, Identifier::from(owner), label, salt, salt) + .map_err(|e| format!("unable to build DPNS domain document: {e}"))?; + build_document_transition( + &contract, + "domain", + domain, + identity_contract_nonce, + BatchKind::Create { entropy: salt }, + key, + sign_fn, + version, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_profile( + version: &PlatformVersion, + identity_id: &[u8], + identity_contract_nonce: u64, + display_name: &str, + public_message: &str, + avatar_url: &str, + avatar_hash: &[u8], + avatar_fingerprint: &[u8], + revision: u64, + existing_document_id: Option<&[u8]>, + entropy: &[u8], + key: &KeyInfo, + sign_fn: SignFn<'_>, +) -> Result { + let owner = id32(identity_id, "identity id")?; + let entropy = id32(entropy, "entropy")?; + if !avatar_hash.is_empty() && avatar_hash.len() != 32 { + return Err("avatar hash must be 32 bytes".to_string()); + } + if !avatar_fingerprint.is_empty() && avatar_fingerprint.len() != 8 { + return Err("avatar fingerprint must be 8 bytes".to_string()); + } + // All profile fields are optional in the DashPay contract; $createdAt / + // $updatedAt are assigned by the chain and never appear in the + // transition. + let mut properties = BTreeMap::new(); + for (name, text) in [ + ("displayName", display_name), + ("publicMessage", public_message), + ("avatarUrl", avatar_url), + ] { + if !text.is_empty() { + properties.insert(name.to_string(), Value::Text(text.to_string())); + } + } + if !avatar_hash.is_empty() { + properties.insert( + "avatarHash".to_string(), + Value::Bytes32(id32(avatar_hash, "avatar hash")?), + ); + } + if !avatar_fingerprint.is_empty() { + properties.insert( + "avatarFingerprint".to_string(), + Value::Bytes(avatar_fingerprint.to_vec()), + ); + } + + let Some(existing_document_id) = existing_document_id else { + if revision != 1 { + return Err("profile create requires revision 1".to_string()); + } + return build_document_create( + SystemDataContract::Dashpay, + "profile", + owner, + identity_contract_nonce, + properties, + entropy, + key, + sign_fn, + version, + ); + }; + + if revision < 2 { + return Err("profile replace requires revision > 1".to_string()); + } + let contract = load_contract(SystemDataContract::Dashpay, version)?; + let document = Document::V0(DocumentV0 { + id: Identifier::from(id32(existing_document_id, "existing document id")?), + owner_id: Identifier::from(owner), + properties, + revision: Some(revision), + ..Default::default() + }); + build_document_transition( + &contract, + "profile", + document, + identity_contract_nonce, + BatchKind::Replace, + key, + sign_fn, + version, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_contact_request( + version: &PlatformVersion, + identity_id: &[u8], + identity_contract_nonce: u64, + to_user_id: &[u8], + encrypted_public_key: &[u8], + sender_key_index: u32, + recipient_key_index: u32, + account_reference: u32, + encrypted_account_label: &[u8], + entropy: &[u8], + key: &KeyInfo, + sign_fn: SignFn<'_>, +) -> Result { + let owner = id32(identity_id, "identity id")?; + let to_user = id32(to_user_id, "to-user id")?; + let entropy = id32(entropy, "entropy")?; + let contract = load_contract(SystemDataContract::Dashpay, version)?; + // The shared builder checks the crypto-material sizes against the + // contract schema and assembles the DIP-15 property map; $createdAt and + // $createdAtCoreBlockHeight are chain-assigned system fields, so they do + // not enter the transition. + let document = build_contact_request_document( + &contract, + ContactRequestDocumentParams { + sender_id: Identifier::from(owner), + recipient_id: Identifier::from(to_user), + sender_key_index, + recipient_key_index, + account_reference, + encrypted_public_key: encrypted_public_key.to_vec(), + encrypted_account_label: (!encrypted_account_label.is_empty()) + .then(|| encrypted_account_label.to_vec()), + auto_accept_proof: None, + entropy, + }, + ) + .map_err(|e| format!("unable to build contact request document: {e}"))?; + build_document_transition( + &contract, + "contactRequest", + document, + identity_contract_nonce, + BatchKind::Create { entropy }, + key, + sign_fn, + version, + ) +} + +/// Asset lock proof input for identity registration. +pub enum AssetLockProofInput { + Instant { + /// Serialized asset lock transaction. + transaction: Vec, + /// Serialized islock message. + instant_lock: Vec, + /// Index of the asset-lock OP_RETURN output in tx.vout. + output_index: u32, + }, + Chain { + core_chain_locked_height: u32, + /// txid || vout (LE u32), consensus encoding. + out_point: [u8; 36], + }, +} + +/// A public key to register with a new identity. The private key never +/// crosses; each key proves ownership through the signing callback. +pub struct NewIdentityKey { + pub id: u32, + pub purpose: u8, + pub security_level: u8, + /// Compressed secp256k1 public key (33 bytes). + pub pubkey: Vec, +} + +/// IdentityCreateTransition assembled manually so that both the identity +/// keys and the asset-lock one-time key stay behind the signing callback. +/// Mirrors rs-dpp `try_from_identity_with_signer_and_private_key`: all +/// per-key signatures and the outer asset-lock signature cover the same +/// signable bytes (key signatures, the outer signature and the identity id +/// are all excluded from the signable form). +pub fn build_identity_create( + version: &PlatformVersion, + proof: AssetLockProofInput, + keys: &[NewIdentityKey], + sign_fn: SignFn<'_>, +) -> Result { + if keys.is_empty() { + return Err("no identity keys provided".to_string()); + } + + let asset_lock_proof = match proof { + AssetLockProofInput::Instant { + transaction, + instant_lock, + output_index, + } => { + // Both are consensus-bounded Core messages; anything larger than a + // block is not one. + const MAX_CORE_MESSAGE: usize = 2 * 1024 * 1024; + if transaction.len() > MAX_CORE_MESSAGE || instant_lock.len() > MAX_CORE_MESSAGE { + return Err("asset lock transaction or instant lock exceeds 2 MiB".to_string()); + } + let transaction = Transaction::consensus_decode(&mut transaction.as_slice()) + .map_err(|e| format!("bad asset lock transaction: {e}"))?; + let instant_lock = InstantLock::consensus_decode(&mut instant_lock.as_slice()) + .map_err(|e| format!("bad instant lock: {e}"))?; + AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + transaction, + output_index, + )) + } + AssetLockProofInput::Chain { + core_chain_locked_height, + out_point, + } => { + let out_point = OutPoint::consensus_decode(&mut out_point.as_slice()) + .map_err(|e| format!("bad asset lock outpoint: {e}"))?; + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height, + out_point, + }) + } + }; + + // rs-dpp registers Identity::public_keys() (a BTreeMap keyed by id), so + // the keys serialize in ascending id order with no duplicates. + let mut sorted: Vec<&NewIdentityKey> = keys.iter().collect(); + sorted.sort_by_key(|key| key.id); + let mut public_keys = Vec::with_capacity(sorted.len()); + for (i, key) in sorted.iter().enumerate() { + if i > 0 && key.id == sorted[i - 1].id { + return Err(format!("duplicate identity key id {}", key.id)); + } + if key.pubkey.len() != COMPRESSED_PUBKEY_SIZE { + return Err(format!( + "identity key {}: unexpected public key size {}", + key.id, + key.pubkey.len() + )); + } + public_keys.push( + IdentityPublicKeyInCreationV0 { + id: key.id, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::try_from(key.purpose) + .map_err(|e| format!("identity key {}: bad purpose: {e}", key.id))?, + security_level: SecurityLevel::try_from(key.security_level) + .map_err(|e| format!("identity key {}: bad security level: {e}", key.id))?, + contract_bounds: None, + read_only: false, + data: BinaryData::new(key.pubkey.clone()), + signature: Default::default(), + } + .into(), + ); + } + + let identity_id = asset_lock_proof + .create_identifier() + .map_err(|e| format!("unable to derive identity id: {e}"))?; + let mut transition = IdentityCreateTransitionV0 { + public_keys, + asset_lock_proof, + user_fee_increase: 0, + identity_id, + ..Default::default() + }; + + // Every registered key proves ownership by signing the same digest as + // the asset-lock key: the double-SHA256 of the signable bytes. Key + // signatures are excluded from the signable form, so setting them does + // not change it. + let state_transition: StateTransition = transition.clone().into(); + let signable = state_transition + .signable_bytes() + .map_err(|e| format!("unable to compute signable bytes: {e}"))?; + let digest = hash_double(&signable); + for key in transition.public_keys.iter_mut() { + let signature = sign_digest(sign_fn, key.id(), digest) + .map_err(|e| format!("identity key {}: {e}", key.id()))?; + key.set_signature(BinaryData::new(signature)); + } + let mut state_transition: StateTransition = transition.into(); + let signature = sign_digest(sign_fn, ASSET_LOCK_KEY_ID, digest) + .map_err(|e| format!("asset lock key: {e}"))?; + if !state_transition.set_signature(BinaryData::new(signature)) { + return Err("unable to set asset lock signature".to_string()); + } + built(&state_transition, version) +} diff --git a/packages/rs-platform-cxx/src/types.rs b/packages/rs-platform-cxx/src/types.rs new file mode 100644 index 00000000000..4146e274de5 --- /dev/null +++ b/packages/rs-platform-cxx/src/types.rs @@ -0,0 +1,126 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! Plain-Rust result types shared by the core modules. The cxx bridge in +//! `lib.rs` converts these into the flat shared structs C++ sees. + +/// One identity public key, flattened for FFI use. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyInfo { + pub id: u32, + pub purpose: u8, + pub security_level: u8, + pub key_type: u8, + pub read_only: bool, + pub data: Vec, + pub disabled_at: Option, +} + +/// Decoded contested-resource vote state (getContestedResourceVoteState, +/// result type VoteTally with locked and abstaining tallies). +#[derive(Debug, Clone, Default)] +pub struct ContestedVoteState { + pub contest_found: bool, + /// identity -> votes + pub contenders: Vec<([u8; 32], Option)>, + pub abstain_votes: Option, + pub lock_votes: Option, + pub finished: bool, + pub locked: bool, + pub winner: Option<[u8; 32]>, + pub finished_at_time_ms: u64, +} + +/// Decoded identity. +#[derive(Debug, Clone, Default)] +pub struct IdentityInfo { + pub id: [u8; 32], + pub balance: u64, + pub revision: u64, + pub keys: Vec, +} + +/// Decoded DPNS `domain` document fields exposed through this binding. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DpnsName { + pub label: String, + pub normalized_label: String, + /// normalizedParentDomainName ("dash") + pub parent_domain: String, + pub identity: [u8; 32], + pub document_id: [u8; 32], + pub owner_id: [u8; 32], +} + +/// Decoded DashPay `profile` document. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Profile { + pub document_id: [u8; 32], + pub owner_id: [u8; 32], + pub display_name: String, + pub public_message: String, + pub avatar_url: String, + pub avatar_hash: Vec, + pub avatar_fingerprint: Vec, + pub created_at: u64, + pub updated_at: u64, + pub revision: u64, +} + +/// Decoded DashPay `contactRequest` document. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ContactRequest { + pub document_id: [u8; 32], + pub owner_id: [u8; 32], + pub to_user_id: [u8; 32], + pub encrypted_public_key: Vec, + pub sender_key_index: u32, + pub recipient_key_index: u32, + pub account_reference: u32, + pub encrypted_account_label: Vec, + pub core_height_created_at: u32, + pub created_at: u64, +} + +/// The authenticated ResponseMetadata fields of a verified DAPI response. +/// The Tenderdash quorum signature covers all of them (they enter the +/// StateId / CanonicalVote sign bytes), and [`crate::client::Client::accept`] +/// has already applied the chain-id and ChainLock-floor checks by the time +/// a `Meta` reaches the embedder. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Meta { + pub height: u64, + pub core_chain_locked_height: u32, + pub time_ms: u64, + pub protocol_version: u32, + pub chain_id: String, +} + +/// A built, signed state transition. +#[derive(Debug, Clone)] +pub struct BuiltTransition { + /// Serialized signed state transition. + pub bytes: Vec, + /// sha256(bytes) - the wait handle for waitForStateTransitionResult. + pub hash: [u8; 32], +} + +/// A node's rejection of a broadcast state transition (DAPI error code and +/// message). Informational: not proof-backed. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BroadcastRejection { + pub code: u32, + pub message: String, +} + +/// Copies `bytes` into a fixed-width array, naming `what` in the error. +pub fn fixed(bytes: &[u8], what: &str) -> Result<[u8; N], String> { + bytes + .try_into() + .map_err(|_| format!("{what} must be {N} bytes, got {}", bytes.len())) +} + +pub fn id32(bytes: &[u8], what: &str) -> Result<[u8; 32], String> { + fixed(bytes, what) +} diff --git a/packages/rs-platform-cxx/test_data/dpp_identity_vectors.json b/packages/rs-platform-cxx/test_data/dpp_identity_vectors.json new file mode 100644 index 00000000000..b4e6cb3c4ce --- /dev/null +++ b/packages/rs-platform-cxx/test_data/dpp_identity_vectors.json @@ -0,0 +1,52 @@ +{ + "platform_repo_tag": "v4.0.0", + "protocol_version": 12, + "identity": { + "serialized_hex": "00777777777777777777777777777777777777777777777777777777777777777703000000000000000021034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa0001000100020000002102466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f270002000201030100a2a1b4ac6fef22ea2a1a68e8123644b357875f6b412c18109281c146e7b271bc000021035ab4689e400a4a160cf01cd44730845a54768df8547dcdf073d964f109f18c3001fd0000018bcfe5687bfd000000012a05f20003", + "id": "7777777777777777777777777777777777777777777777777777777777777777", + "balance": 5000000000, + "revision": 3, + "public_keys": [ + { + "id": 0, + "purpose": 0, + "security_level": 0, + "key_type": 0, + "read_only": false, + "data": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa", + "disabled_at": null + }, + { + "id": 1, + "purpose": 0, + "security_level": 2, + "key_type": 0, + "read_only": false, + "data": "02466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27", + "disabled_at": null + }, + { + "id": 2, + "purpose": 1, + "security_level": 3, + "key_type": 0, + "read_only": false, + "data": "035ab4689e400a4a160cf01cd44730845a54768df8547dcdf073d964f109f18c30", + "disabled_at": 1700000000123 + } + ], + "bounded_key_contract_id": "a2a1b4ac6fef22ea2a1a68e8123644b357875f6b412c18109281c146e7b271bc" + }, + "identity_public_key": { + "serialized_hex": "000100020000002102466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f2700", + "fields": { + "id": 1, + "purpose": 0, + "security_level": 2, + "key_type": 0, + "read_only": false, + "data": "02466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27", + "disabled_at": null + } + } +} diff --git a/packages/rs-platform-cxx/test_data/dpp_st_vectors.json b/packages/rs-platform-cxx/test_data/dpp_st_vectors.json new file mode 100644 index 00000000000..3b7a9859420 --- /dev/null +++ b/packages/rs-platform-cxx/test_data/dpp_st_vectors.json @@ -0,0 +1,233 @@ +{ + "platform_repo_tag": "v4.0.0", + "protocol_version": 12, + "signature_scheme": "double-SHA256 of signable bytes; 65-byte compact recoverable ECDSA; header byte = 27 + recovery_id + 4 (compressed)", + "keys": { + "master": { + "id": 0, + "private_key_hex": "1111111111111111111111111111111111111111111111111111111111111111", + "public_key_hex": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" + }, + "high": { + "id": 1, + "private_key_hex": "2222222222222222222222222222222222222222222222222222222222222222", + "public_key_hex": "02466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27" + }, + "asset_lock": { + "private_key_hex": "3333333333333333333333333333333333333333333333333333333333333333", + "public_key_hex": "023c72addb4fdf09af94f0c94d7fe92a386a7e70cf8a1d85916386bb2535c7b1b1" + } + }, + "identity_create_instant": { + "signable_hex": "0300020000000000000021034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa000100000200002102466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f2700c60101aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa000000004ad31484097f16240507943e38ac9219114ede07c5794dc29314303070ea6bf9bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc620300080001dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd0100000000ffffffff01a086010000000000016a00000000240101a0860100000000001976a914999999999999999999999999999999999999999988ac0000", + "digest_hex": "65e4f7fd5b7aed2f0eff726970885384934e992b069f69a691e2ca719e9bccb0", + "serialized_hex": "0300020000000000000021034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa411f9d83c3c0beb114a82d086a738e3aa5b13a0e70b8add2c78ac3cb90e592bdc4ba299caa0e78460bd08c54b7fb8d7e78879907bd0fc54fb6bfcc69a540ea0de84d000100000200002102466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27411ffc748e31ff48cbcfc2fa304bee54fdb8eaf673d5ab2da8ceb48f8d57dbc83def2e6d9970e108dadf18567d26ecbea8ac457768dc92dc1bdefdf52faac9f7988200c60101aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa000000004ad31484097f16240507943e38ac9219114ede07c5794dc29314303070ea6bf9bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc620300080001dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd0100000000ffffffff01a086010000000000016a00000000240101a0860100000000001976a914999999999999999999999999999999999999999988ac0000411f705a1b57222944f2ce9ea0d728edbc1312f244e326236946336c59e0f8b180ad732d88514d33de9a6f3687a789ae44649835c9f6c55a1e1bbcae6efc7f899b0c15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "transaction_hex": "0300080001dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd0100000000ffffffff01a086010000000000016a00000000240101a0860100000000001976a914999999999999999999999999999999999999999988ac", + "instant_lock_hex": "0101aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa000000004ad31484097f16240507943e38ac9219114ede07c5794dc29314303070ea6bf9bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "output_index": 0, + "out_point_hex": "4ad31484097f16240507943e38ac9219114ede07c5794dc29314303070ea6bf900000000", + "identity_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327" + }, + "identity_create_chain": { + "signable_hex": "0300020000000000000021034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa000100000200002102466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f2701fc00100590204ad31484097f16240507943e38ac9219114ede07c5794dc29314303070ea6bf90000", + "digest_hex": "0a0a332c8bb7cb633707f8aa33ceffd667484e0fcf9d78f8025d3478f7c3b324", + "serialized_hex": "0300020000000000000021034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa41200a0b660530dfce50a0b1638ec274f658d662c1755105cfcae5ad31f92af5b0622dee03deefd30f0fa679d729bc89b138e402fc72ad9eee102dafcf2c580ef218000100000200002102466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27411fcd6fc90f5a63a47a7dd2fd037a08570298c9aba925d2bbb2392f6a6f52cc109f23de5102d4b3d2e471a75c875f46dcf0d9ad5be3e6215c014150c77b0b8db62a01fc00100590204ad31484097f16240507943e38ac9219114ede07c5794dc29314303070ea6bf900004120d275a61395575038d64f674b8fff1199abef5aae24ca7e560103a67cf71ea021080b97f7c4a7eea3fbe5045695404a766221556f283dcacc08b49beb9568a75915eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "core_chain_locked_height": 1050000, + "out_point_hex": "4ad31484097f16240507943e38ac9219114ede07c5794dc29314303070ea6bf900000000", + "identity_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327" + }, + "dpns_preorder": { + "signable_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270100000001fae3f9bb1a28d38c18c65e78ae91730c667f388911988dce5c695f39cba97b1002087072656f72646572e668c659af66aee1e72c186dde7b5b7e0a1d712a09c40d5721f622bf53c53155002d07513b62deb2a49393aef02a84a9ad522c0e4f4b9c88fcec184cce7108bc18011073616c746564446f6d61696e486173680c2d07513b62deb2a49393aef02a84a9ad522c0e4f4b9c88fcec184cce7108bc180000", + "digest_hex": "64ad2dc780ce0718457c2f3ae9b3a6c6c49605ad71ea977e7746c755108fd24b", + "serialized_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270100000001fae3f9bb1a28d38c18c65e78ae91730c667f388911988dce5c695f39cba97b1002087072656f72646572e668c659af66aee1e72c186dde7b5b7e0a1d712a09c40d5721f622bf53c53155002d07513b62deb2a49393aef02a84a9ad522c0e4f4b9c88fcec184cce7108bc18011073616c746564446f6d61696e486173680c2d07513b62deb2a49393aef02a84a9ad522c0e4f4b9c88fcec184cce7108bc1800000141209dcb265b869db7435ee79271ae2ded9fbda2c13478b2c65ca1b087c37226df0716917a41b8233a2b1982518e2d127bb9f2c654cfdb52e902233d2b4c85e8f552", + "owner_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "identity_contract_nonce": 2, + "entropy_hex": "2d07513b62deb2a49393aef02a84a9ad522c0e4f4b9c88fcec184cce7108bc18", + "salt_hex": "5555555555555555555555555555555555555555555555555555555555555555", + "label": "Alice", + "normalized_label": "a11ce", + "salted_domain_hash_hex": "2d07513b62deb2a49393aef02a84a9ad522c0e4f4b9c88fcec184cce7108bc18", + "document_id_hex": "fae3f9bb1a28d38c18c65e78ae91730c667f388911988dce5c695f39cba97b10", + "signature_public_key_id": 1 + }, + "dpns_domain_contested": { + "signable_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc32701000000018e59141aa8875fbe1b6a1089bef3366a9f8aeddb4debcea35905ced54228ba87fb123406646f6d61696ee668c659af66aee1e72c186dde7b5b7e0a1d712a09c40d5721f622bf53c5315500555555555555555555555555555555555555555555555555555555555555555507056c6162656c1205416c6963650f6e6f726d616c697a65644c6162656c120561313163651a6e6f726d616c697a6564506172656e74446f6d61696e4e616d6512046461736810706172656e74446f6d61696e4e616d651204646173680c7072656f7264657253616c740c5555555555555555555555555555555555555555555555555555555555555555077265636f726473160112086964656e746974791015eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270e737562646f6d61696e52756c65731601120f616c6c6f77537562646f6d61696e7313000112706172656e744e616d65416e644c6162656cfd00000004a817c80000", + "digest_hex": "cdddcc3a001075acb4a13546f4d5404df42ae5ee857f0641c8199ac64bd3a90f", + "serialized_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc32701000000018e59141aa8875fbe1b6a1089bef3366a9f8aeddb4debcea35905ced54228ba87fb123406646f6d61696ee668c659af66aee1e72c186dde7b5b7e0a1d712a09c40d5721f622bf53c5315500555555555555555555555555555555555555555555555555555555555555555507056c6162656c1205416c6963650f6e6f726d616c697a65644c6162656c120561313163651a6e6f726d616c697a6564506172656e74446f6d61696e4e616d6512046461736810706172656e74446f6d61696e4e616d651204646173680c7072656f7264657253616c740c5555555555555555555555555555555555555555555555555555555555555555077265636f726473160112086964656e746974791015eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270e737562646f6d61696e52756c65731601120f616c6c6f77537562646f6d61696e7313000112706172656e744e616d65416e644c6162656cfd00000004a817c800000141200307c3087cacd3b3aecbbefae2191c53f02e8b6b58efff84661df8a8ff674eff213f285ddd0f5030e9457d918a925bb1b6d2e3305605508ed26ec6e2fc17acf2", + "owner_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "identity_contract_nonce": 4660, + "entropy_hex": "5555555555555555555555555555555555555555555555555555555555555555", + "salt_hex": "5555555555555555555555555555555555555555555555555555555555555555", + "label": "Alice", + "normalized_label": "a11ce", + "document_id_hex": "8e59141aa8875fbe1b6a1089bef3366a9f8aeddb4debcea35905ced54228ba87", + "signature_public_key_id": 1, + "contested": true, + "prefunded_voting_balance_credits": 20000000000 + }, + "dpns_domain": { + "signable_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc32701000000018e59141aa8875fbe1b6a1089bef3366a9f8aeddb4debcea35905ced54228ba870506646f6d61696ee668c659af66aee1e72c186dde7b5b7e0a1d712a09c40d5721f622bf53c5315500555555555555555555555555555555555555555555555555555555555555555507056c6162656c12097175616e74756d34320f6e6f726d616c697a65644c6162656c12097175616e74756d34321a6e6f726d616c697a6564506172656e74446f6d61696e4e616d6512046461736810706172656e74446f6d61696e4e616d651204646173680c7072656f7264657253616c740c5555555555555555555555555555555555555555555555555555555555555555077265636f726473160112086964656e746974791015eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270e737562646f6d61696e52756c65731601120f616c6c6f77537562646f6d61696e7313000000", + "digest_hex": "10b93c345995e3fca59bfbb45e6ecc0a794b50f0b9f42de9002d0f935c32a896", + "serialized_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc32701000000018e59141aa8875fbe1b6a1089bef3366a9f8aeddb4debcea35905ced54228ba870506646f6d61696ee668c659af66aee1e72c186dde7b5b7e0a1d712a09c40d5721f622bf53c5315500555555555555555555555555555555555555555555555555555555555555555507056c6162656c12097175616e74756d34320f6e6f726d616c697a65644c6162656c12097175616e74756d34321a6e6f726d616c697a6564506172656e74446f6d61696e4e616d6512046461736810706172656e74446f6d61696e4e616d651204646173680c7072656f7264657253616c740c5555555555555555555555555555555555555555555555555555555555555555077265636f726473160112086964656e746974791015eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270e737562646f6d61696e52756c65731601120f616c6c6f77537562646f6d61696e731300000001411f63a478bc0626c371ee445ba205244227b734d540a89278ca8cedbe9e48bd88bc215e9871ce443c04d1dacc91c080de6151da51be58225b908e59d33dc1e8c038", + "owner_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "identity_contract_nonce": 5, + "entropy_hex": "5555555555555555555555555555555555555555555555555555555555555555", + "salt_hex": "5555555555555555555555555555555555555555555555555555555555555555", + "label": "quantum42", + "normalized_label": "quantum42", + "document_id_hex": "8e59141aa8875fbe1b6a1089bef3366a9f8aeddb4debcea35905ced54228ba87", + "signature_public_key_id": 1, + "contested": false + }, + "dashpay_profile_create": { + "signable_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270100000001a682c7dff71614a696c989e14a94f7841106620522ede675ef453238751a21d6030770726f66696c65a2a1b4ac6fef22ea2a1a68e8123644b357875f6b412c18109281c146e7b271bc00e540d547ecac78cac1efd88fbced3d23aa8ae638ff1accdaa8a308337983eb09051161766174617246696e6765727072696e740a0899999999999999990a617661746172486173680c88888888888888888888888888888888888888888888888888888888888888880961766174617255726c121968747470733a2f2f6578616d706c652e636f6d2f612e706e670b646973706c61794e616d651213416c69636520d0b220576f6e6465726c616e640d7075626c69634d657373616765120e68656c6c6f20706c6174666f726d0000", + "digest_hex": "9e375dc31f1323adc4cc2e1de0eb5f8e0c6a26d26288b6e74f3b9cd68ea7afe0", + "serialized_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270100000001a682c7dff71614a696c989e14a94f7841106620522ede675ef453238751a21d6030770726f66696c65a2a1b4ac6fef22ea2a1a68e8123644b357875f6b412c18109281c146e7b271bc00e540d547ecac78cac1efd88fbced3d23aa8ae638ff1accdaa8a308337983eb09051161766174617246696e6765727072696e740a0899999999999999990a617661746172486173680c88888888888888888888888888888888888888888888888888888888888888880961766174617255726c121968747470733a2f2f6578616d706c652e636f6d2f612e706e670b646973706c61794e616d651213416c69636520d0b220576f6e6465726c616e640d7075626c69634d657373616765120e68656c6c6f20706c6174666f726d000001411fd491cd71660321908cb4b911d103ec22252cb60f70f073f0d0468dce50ef31aa0de158d69459d3fdb4e177471ed19857cd5f504e2c8becd8fd6f8b57dd8dc455", + "owner_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "identity_contract_nonce": 3, + "entropy_hex": "e540d547ecac78cac1efd88fbced3d23aa8ae638ff1accdaa8a308337983eb09", + "document_id_hex": "a682c7dff71614a696c989e14a94f7841106620522ede675ef453238751a21d6", + "signature_public_key_id": 1, + "display_name": "Alice в Wonderland", + "public_message": "hello platform", + "avatar_url": "https://example.com/a.png", + "avatar_hash_hex": "8888888888888888888888888888888888888888888888888888888888888888", + "avatar_fingerprint_hex": "9999999999999999" + }, + "dashpay_profile_replace": { + "signable_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270100010001a682c7dff71614a696c989e14a94f7841106620522ede675ef453238751a21d6040770726f66696c65a2a1b4ac6fef22ea2a1a68e8123644b357875f6b412c18109281c146e7b271bc0002051161766174617246696e6765727072696e740a0899999999999999990a617661746172486173680c88888888888888888888888888888888888888888888888888888888888888880961766174617255726c121968747470733a2f2f6578616d706c652e636f6d2f612e706e670b646973706c61794e616d651213416c69636520d0b220576f6e6465726c616e640d7075626c69634d657373616765120f75706461746564206d65737361676500", + "digest_hex": "f3361516f52e272dfc6fdc75ea304f9c5035cf985550730a3704694cc0a755d1", + "serialized_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270100010001a682c7dff71614a696c989e14a94f7841106620522ede675ef453238751a21d6040770726f66696c65a2a1b4ac6fef22ea2a1a68e8123644b357875f6b412c18109281c146e7b271bc0002051161766174617246696e6765727072696e740a0899999999999999990a617661746172486173680c88888888888888888888888888888888888888888888888888888888888888880961766174617255726c121968747470733a2f2f6578616d706c652e636f6d2f612e706e670b646973706c61794e616d651213416c69636520d0b220576f6e6465726c616e640d7075626c69634d657373616765120f75706461746564206d65737361676500014120fd2ec82c57e630ce2854a2ec05a14f36d186cb030707bd725b1ae6d79ce7a6194fa8edfba509bbc6f4f4a99a77270a34ec8c345234019078b793a4e5fe9cd33e", + "owner_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "identity_contract_nonce": 4, + "revision": 2, + "document_id_hex": "a682c7dff71614a696c989e14a94f7841106620522ede675ef453238751a21d6", + "signature_public_key_id": 1, + "public_message": "updated message" + }, + "dashpay_contact_request": { + "signable_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327010000000148880e37b469540c0b4b8a6489d21938726e53ce7d337febb1f101da351f57fd060e636f6e7461637452657175657374a2a1b4ac6fef22ea2a1a68e8123644b357875f6b412c18109281c146e7b271bc00fda853b87e9145263b6fcb17b4d8545a9462b4daf95a86194b5e8b5a3f74a22406106163636f756e745265666572656e636504fc0badc0de15656e637279707465644163636f756e744c6162656c0a30cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd12656e637279707465645075626c69634b65790a60abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab11726563697069656e744b6579496e64657804030e73656e6465724b6579496e646578040208746f55736572496410eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000", + "digest_hex": "46002d99e2fcdf47cddc3e3173b9ba9256af84e84a4b6e007b2ba4db848cab44", + "serialized_hex": "020115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327010000000148880e37b469540c0b4b8a6489d21938726e53ce7d337febb1f101da351f57fd060e636f6e7461637452657175657374a2a1b4ac6fef22ea2a1a68e8123644b357875f6b412c18109281c146e7b271bc00fda853b87e9145263b6fcb17b4d8545a9462b4daf95a86194b5e8b5a3f74a22406106163636f756e745265666572656e636504fc0badc0de15656e637279707465644163636f756e744c6162656c0a30cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd12656e637279707465645075626c69634b65790a60abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab11726563697069656e744b6579496e64657804030e73656e6465724b6579496e646578040208746f55736572496410eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000001411fcbc9c34bb69e53b1a319499aada671d480cb8db8e33afd27a633ee21b98dd1c87c8d27c8e2a878edcee85d92fa614bd8c9c3724f86854971a5dadfa31d9e7c2e", + "owner_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "identity_contract_nonce": 6, + "entropy_hex": "fda853b87e9145263b6fcb17b4d8545a9462b4daf95a86194b5e8b5a3f74a224", + "document_id_hex": "48880e37b469540c0b4b8a6489d21938726e53ce7d337febb1f101da351f57fd", + "signature_public_key_id": 1, + "to_user_id_hex": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "encrypted_public_key_hex": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "sender_key_index": 2, + "recipient_key_index": 3, + "account_reference": 195936478, + "encrypted_account_label_hex": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + }, + "contested_labels": [ + { + "label": "alice", + "normalized": "a11ce", + "contested": true + }, + { + "label": "a11ce", + "normalized": "a11ce", + "contested": true + }, + { + "label": "bob", + "normalized": "b0b", + "contested": true + }, + { + "label": "b0b", + "normalized": "b0b", + "contested": true + }, + { + "label": "ab", + "normalized": "ab", + "contested": false + }, + { + "label": "abc", + "normalized": "abc", + "contested": true + }, + { + "label": "x2y", + "normalized": "x2y", + "contested": false + }, + { + "label": "up", + "normalized": "up", + "contested": false + }, + { + "label": "-ab-", + "normalized": "-ab-", + "contested": true + }, + { + "label": "aaaaaaaaaaaaaaaaaaa", + "normalized": "aaaaaaaaaaaaaaaaaaa", + "contested": true + }, + { + "label": "aaaaaaaaaaaaaaaaaaaa", + "normalized": "aaaaaaaaaaaaaaaaaaaa", + "contested": false + }, + { + "label": "quantum42", + "normalized": "quantum42", + "contested": false + }, + { + "label": "dash", + "normalized": "dash", + "contested": true + }, + { + "label": "test-name", + "normalized": "test-name", + "contested": true + }, + { + "label": "name2", + "normalized": "name2", + "contested": false + } + ], + "stored_documents": { + "domain": { + "serialized_hex": "028e59141aa8875fbe1b6a1089bef3366a9f8aeddb4debcea35905ced54228ba8715eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327000100070000018bcfe568000000018bcfe568640000018bcfe568c800097175616e74756d3432097175616e74756d3432010464617368046461736800210115eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270100", + "id_hex": "8e59141aa8875fbe1b6a1089bef3366a9f8aeddb4debcea35905ced54228ba87", + "owner_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "identity_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "label": "quantum42", + "normalized_label": "quantum42" + }, + "profile": { + "serialized_hex": "02a682c7dff71614a696c989e14a94f7841106620522ede675ef453238751a21d615eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc3270200030000018bcfe568000000018bcfe5692c011968747470733a2f2f6578616d706c652e636f6d2f612e706e67018888888888888888888888888888888888888888888888888888888888888888019999999999999999010f75706461746564206d6573736167650113416c69636520d0b220576f6e6465726c616e64", + "id_hex": "a682c7dff71614a696c989e14a94f7841106620522ede675ef453238751a21d6", + "owner_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "revision": 2, + "display_name": "Alice в Wonderland", + "public_message": "updated message", + "avatar_url": "https://example.com/a.png", + "created_at": 1700000000000, + "updated_at": 1700000000300 + }, + "contact": { + "serialized_hex": "0248880e37b469540c0b4b8a6489d21938726e53ce7d337febb1f101da351f57fd15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc32700410000018bcfe56990001e8480eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeabababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab00000000000000020000000000000003000000000badc0de0130cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd00", + "id_hex": "48880e37b469540c0b4b8a6489d21938726e53ce7d337febb1f101da351f57fd", + "owner_id_hex": "15eccc3165b78a43d2a70bf8421d8e286bc6330790a4ef4ed09316f0eb0bc327", + "to_user_id_hex": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "sender_key_index": 2, + "recipient_key_index": 3, + "account_reference": 195936478, + "created_at": 1700000000400, + "core_height_created_at": 2000000 + } + } +} 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..17aa8c265ce --- /dev/null +++ b/packages/rs-platform-cxx/tests/common/mod.rs @@ -0,0 +1,69 @@ +// 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: a [`Client`] driven by `dash-sdk`'s mock transport +//! (no network), configured the way an embedder configures the real one. + +#![allow(dead_code)] + +use std::sync::Arc; + +use dash_platform_cxx::client::Client; +use dash_platform_cxx::provider::{Context, QuorumKey}; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::{Sdk, SdkBuilder}; +use platform_version::version::PlatformVersion; + +/// The Platform LLMQ type of the fixture network. +pub const QUORUM_TYPE: u32 = 106; +/// Tenderdash chain id the fixtures were signed for. +pub const CHAIN_ID: &str = "dash-testnet-51"; +/// Protocol version the fixtures were generated under. +pub const PROTOCOL_VERSION: u32 = 12; + +pub fn hex_vec(s: &str) -> Vec { + hex::decode(s).expect("fixture hex") +} + +pub fn hex32(s: &str) -> [u8; 32] { + hex_vec(s).try_into().expect("32 bytes") +} + +pub fn fixture_context() -> Context { + Context { + network: Network::Testnet, + platform_quorum_type: QUORUM_TYPE, + tenderdash_chain_id: CHAIN_ID.to_string(), + protocol_version: PROTOCOL_VERSION, + platform_activation_height: 0, + } +} + +/// A client whose SDK replays mock expectations instead of talking to a +/// node. The mock SDK shares the client's context provider, so quorum keys +/// pushed through the client reach the verifier exactly as in production. +pub fn mock_client() -> Client { + let client = Client::new(); + client + .set_context(fixture_context()) + .expect("fixture context"); + let sdk: Sdk = SdkBuilder::new_mock() + .with_network(Network::Testnet) + .with_context_provider(Arc::clone(client.provider())) + .with_version(PlatformVersion::get(PROTOCOL_VERSION).expect("fixture version")) + .build() + .expect("mock sdk"); + client.set_sdk(sdk).expect("install mock sdk"); + client +} + +pub fn push_quorum_key(client: &Client, quorum_hash: [u8; 32], public_key: [u8; 48]) { + client + .provider() + .update_quorum_keys(vec![QuorumKey { + quorum_hash, + public_key, + }]) + .expect("quorum keys"); +} 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..e600e572afd --- /dev/null +++ b/packages/rs-platform-cxx/tests/cxx_smoke.cc @@ -0,0 +1,142 @@ +// 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: client construction and context setup, a query without endpoints +// (expected to throw rust::Error rather than abort), quorum-key and +// endpoint plumbing, and every builder driven through a WalletSigner +// callback (refused because the signer declines). + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +int fail(const char* what) +{ + std::fprintf(stderr, "cxx_smoke: %s\n", what); + return 1; +} + +} // namespace + +int main() +{ + rust::Box client = platform_ffi::new_platform_client(); + try { + client->set_context("test", std::uint32_t{106}, "dash-testnet-51", std::uint32_t{12}, + std::uint32_t{0}); + } catch (const rust::Error& e) { + return fail(e.what()); + } + + // A query before any endpoint is known must surface as an exception, + // never an abort. + const std::array id{}; + const rust::Slice id_slice(id.data(), id.size()); + bool threw = false; + try { + client->get_identity(id_slice); + } catch (const rust::Error&) { + threw = true; + } + if (!threw) return fail("query without endpoints succeeded"); + + // Quorum keys and endpoints round-trip; a malformed key is refused. + try { + rust::Vec keys; + platform_ffi::FfiQuorumKey key; + for (int i = 0; i < 32; ++i) key.quorum_hash.push_back(static_cast(i)); + for (int i = 0; i < 48; ++i) key.pubkey.push_back(static_cast(i)); + keys.push_back(std::move(key)); + client->update_quorum_keys(std::move(keys)); + rust::Vec endpoints; + endpoints.push_back("https://127.0.0.1:1443"); + client->set_endpoints(std::move(endpoints)); + client->set_core_chain_locked_height(std::uint32_t{1}); + } catch (const rust::Error& e) { + return fail(e.what()); + } + threw = false; + try { + rust::Vec keys; + platform_ffi::FfiQuorumKey key; + key.quorum_hash.push_back(0); + keys.push_back(std::move(key)); + client->update_quorum_keys(std::move(keys)); + } catch (const rust::Error&) { + threw = true; + } + if (!threw) return fail("malformed quorum key accepted"); + + // A builder round trip through the WalletSigner callback type. + const platform_ffi::WalletSigner signer( + [](std::uint32_t, const std::array&, std::vector&) { + return false; // wallet declines + }); + platform_ffi::FfiIdentityKey key; + key.id = 1; + key.purpose = 0; + key.security_level = 2; + key.key_type = 0; + key.read_only = false; + for (int i = 0; i < 33; ++i) key.data.push_back(static_cast(i == 0 ? 2 : 0)); + key.has_disabled_at = false; + key.disabled_at = 0; + + // Every builder must reach the same guarded, fallible path: a declined + // signer or a rejected input surfaces as rust::Error from each of them. + const auto expect_error = [](const char* what, auto&& call) { + try { + call(); + } catch (const rust::Error&) { + return 0; + } + return fail(what); + }; + if (expect_error("st_build_dpns_preorder", [&] { + client->st_build_dpns_preorder(id_slice, std::uint64_t{1}, "alice", id_slice, + std::uint32_t{1}, key, signer); + })) + return 1; + if (expect_error("st_build_dpns_domain", [&] { + client->st_build_dpns_domain(id_slice, std::uint64_t{1}, "alice", "a11ce", "dash", + id_slice, std::uint32_t{1}, key, signer); + })) + return 1; + if (expect_error("st_build_profile", [&] { + client->st_build_profile(id_slice, std::uint64_t{1}, "name", "", "", id_slice, + rust::Slice(id.data(), 8), + std::uint64_t{1}, false, id_slice, id_slice, + std::uint32_t{1}, key, signer); + })) + return 1; + if (expect_error("st_build_contact_request", [&] { + std::vector encrypted(96, 0); + client->st_build_contact_request( + id_slice, std::uint64_t{1}, id_slice, + rust::Slice(encrypted.data(), encrypted.size()), 0, 0, 0, + rust::Slice(encrypted.data(), 0), id_slice, std::uint32_t{1}, + key, signer); + })) + return 1; + if (expect_error("st_build_identity_create", [&] { + rust::Vec keys; + client->st_build_identity_create(false, id_slice, id_slice, 0, 0, + rust::Slice(id.data(), 32), + std::move(keys), signer); + })) + return 1; + + // Stored-document decoders reject garbage cleanly. + if (expect_error("decode_dpns_domain", [&] { client->decode_dpns_domain(id_slice); })) return 1; + + client->shutdown(); + client->shutdown(); // idempotent + return 0; +} diff --git a/packages/rs-platform-cxx/tests/decoders.rs b/packages/rs-platform-cxx/tests/decoders.rs new file mode 100644 index 00000000000..5a2233d9e37 --- /dev/null +++ b/packages/rs-platform-cxx/tests/decoders.rs @@ -0,0 +1,189 @@ +// 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. + +//! Decoder tests against the rs-dpp-generated vectors in +//! `test_data/dpp_identity_vectors.json` (identities) and the +//! stored-document fixtures in dpp_st_vectors.json (DPNS domain, DashPay +//! profile and contactRequest documents as Drive stores/returns them). + +use dash_platform_cxx::decode; +use platform_version::version::PlatformVersion; +use serde_json::Value; + +fn hexv(hex: &str) -> Vec { + hex::decode(hex).expect("bad hex in test vector") +} + +/// The vectors were generated at Platform v4.0.0 / protocol version 12; +/// decode under that version. +fn version() -> &'static PlatformVersion { + PlatformVersion::get(12).expect("protocol version 12") +} + +fn identity_vectors() -> Value { + serde_json::from_str(include_str!("../test_data/dpp_identity_vectors.json")) + .expect("parse dpp_identity_vectors.json") +} + +fn st_vectors() -> Value { + serde_json::from_str(include_str!("../test_data/dpp_st_vectors.json")) + .expect("parse dpp_st_vectors.json") +} + +#[test] +fn decode_identity_vector() { + let doc = identity_vectors(); + let vector = &doc["identity"]; + let identity = decode::decode_identity(&hexv(vector["serialized_hex"].as_str().unwrap())) + .expect("decode identity"); + + assert_eq!(hex::encode(identity.id), vector["id"].as_str().unwrap()); + assert_eq!(Some(identity.balance), vector["balance"].as_u64()); + assert_eq!(Some(identity.revision), vector["revision"].as_u64()); + + let expected_keys = vector["public_keys"].as_array().unwrap(); + assert_eq!(identity.keys.len(), expected_keys.len()); + for (key, expected) in identity.keys.iter().zip(expected_keys) { + assert_eq!(Some(u64::from(key.id)), expected["id"].as_u64()); + assert_eq!(Some(u64::from(key.purpose)), expected["purpose"].as_u64()); + assert_eq!( + Some(u64::from(key.security_level)), + expected["security_level"].as_u64() + ); + assert_eq!(Some(u64::from(key.key_type)), expected["key_type"].as_u64()); + assert_eq!(Some(key.read_only), expected["read_only"].as_bool()); + assert_eq!(hex::encode(&key.data), expected["data"].as_str().unwrap()); + assert_eq!(key.disabled_at, expected["disabled_at"].as_u64()); + } +} + +#[test] +fn decode_identity_rejects_garbage() { + assert!(decode::decode_identity(&[0xff; 16]).is_err()); +} + +#[test] +fn decode_stored_dpns_domain() { + let doc = st_vectors(); + let vector = &doc["stored_documents"]["domain"]; + let name = + decode::decode_dpns_domain(&hexv(vector["serialized_hex"].as_str().unwrap()), version()) + .expect("decode DPNS domain"); + assert_eq!( + hex::encode(name.document_id), + vector["id_hex"].as_str().unwrap() + ); + assert_eq!( + hex::encode(name.owner_id), + vector["owner_id_hex"].as_str().unwrap() + ); + assert_eq!( + hex::encode(name.identity), + vector["identity_id_hex"].as_str().unwrap() + ); + assert_eq!(name.label, vector["label"].as_str().unwrap()); + assert_eq!( + name.normalized_label, + vector["normalized_label"].as_str().unwrap() + ); + assert_eq!(name.parent_domain, "dash"); +} + +#[test] +fn decode_stored_dashpay_profile() { + let doc = st_vectors(); + let vector = &doc["stored_documents"]["profile"]; + let profile = decode::decode_dashpay_profile( + &hexv(vector["serialized_hex"].as_str().unwrap()), + version(), + ) + .expect("decode DashPay profile"); + assert_eq!( + hex::encode(profile.document_id), + vector["id_hex"].as_str().unwrap() + ); + assert_eq!( + hex::encode(profile.owner_id), + vector["owner_id_hex"].as_str().unwrap() + ); + assert_eq!(Some(profile.revision), vector["revision"].as_u64()); + assert_eq!( + profile.display_name, + vector["display_name"].as_str().unwrap() + ); + assert_eq!( + profile.public_message, + vector["public_message"].as_str().unwrap() + ); + assert_eq!(profile.avatar_url, vector["avatar_url"].as_str().unwrap()); + assert_eq!(Some(profile.created_at), vector["created_at"].as_u64()); + assert_eq!(Some(profile.updated_at), vector["updated_at"].as_u64()); + assert_eq!(profile.avatar_hash, vec![0x88; 32]); + assert_eq!(profile.avatar_fingerprint, vec![0x99; 8]); +} + +#[test] +fn decode_stored_contact_request() { + let doc = st_vectors(); + let vector = &doc["stored_documents"]["contact"]; + let request = decode::decode_contact_request( + &hexv(vector["serialized_hex"].as_str().unwrap()), + version(), + ) + .expect("decode contact request"); + assert_eq!( + hex::encode(request.document_id), + vector["id_hex"].as_str().unwrap() + ); + assert_eq!( + hex::encode(request.owner_id), + vector["owner_id_hex"].as_str().unwrap() + ); + assert_eq!( + hex::encode(request.to_user_id), + vector["to_user_id_hex"].as_str().unwrap() + ); + assert_eq!( + Some(u64::from(request.sender_key_index)), + vector["sender_key_index"].as_u64() + ); + assert_eq!( + Some(u64::from(request.recipient_key_index)), + vector["recipient_key_index"].as_u64() + ); + assert_eq!( + Some(u64::from(request.account_reference)), + vector["account_reference"].as_u64() + ); + assert_eq!(Some(request.created_at), vector["created_at"].as_u64()); + assert_eq!( + Some(u64::from(request.core_height_created_at)), + vector["core_height_created_at"].as_u64() + ); + assert_eq!(request.encrypted_public_key, vec![0xab; 96]); + assert_eq!(request.encrypted_account_label, vec![0xcd; 48]); +} + +// Documents proven by the drive query vectors are placeholder items, not +// real documents; decoding them must fail cleanly rather than panic. +#[test] +fn decode_rejects_placeholder_documents() { + assert!(decode::decode_dpns_domain(b"proved-dpns-document", version()).is_err()); + assert!(decode::decode_dashpay_profile(b"proved-profile-document", version()).is_err()); + assert!(decode::decode_contact_request(b"proved-contact-document", version()).is_err()); +} + +/// Decoder inputs are bounded like every other byte crossing the bridge. +#[test] +fn oversized_decoder_inputs_are_refused() { + let huge = vec![0u8; dash_platform_cxx::decode::MAX_MESSAGE_BYTES + 1]; + for result in [ + decode::decode_identity(&huge).map(|_| ()), + decode::decode_identity_public_key(&huge).map(|_| ()), + decode::decode_dpns_domain(&huge, version()).map(|_| ()), + ] { + let err = result.expect_err("oversized input"); + assert!(err.contains("above the"), "{err}"); + } +} diff --git a/packages/rs-platform-cxx/tests/queries.rs b/packages/rs-platform-cxx/tests/queries.rs new file mode 100644 index 00000000000..1eb17af7620 --- /dev/null +++ b/packages/rs-platform-cxx/tests/queries.rs @@ -0,0 +1,392 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! The proved queries, replayed through the same client code an embedder +//! runs, against `dash-sdk`'s mock transport fed with drive-proof-verifier's +//! proof-vector corpus. The mock transport hands the SDK the corpus +//! response bytes; 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, exactly as with a live node. Only the socket +//! is mocked. + +mod common; + +use std::path::PathBuf; + +use common::{fixture_context, hex32, hex_vec, mock_client, push_quorum_key, CHAIN_ID}; +use dash_platform_cxx::client::{Client, MAX_CORE_CHAINLOCK_LAG}; +use dash_platform_cxx::queries; +use dash_sdk::dapi_client::transport::TransportRequest; +use dash_sdk::dapi_client::{DumpData, ExecutionResponse}; +use dash_sdk::platform::proto::{self, Proof, ResponseMetadata}; +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::IdentityContractNonceFetcher; +use dash_sdk::{Sdk, SdkBuilder}; +use serde::Deserialize; + +// --- corpus access ---------------------------------------------------------- + +#[derive(Deserialize)] +struct Manifest { + request: serde_json::Value, + expected: serde_json::Value, + block: BlockMeta, + proof_meta: ProofMeta, +} + +#[derive(Deserialize)] +struct BlockMeta { + height: u64, + core_chain_locked_height: u32, + epoch: u16, + time_ms: u64, + protocol_version: u32, + chain_id: String, +} + +#[derive(Deserialize)] +struct ProofMeta { + round: u32, + quorum_type: u32, + quorum_hash_hex: String, + block_id_hash_hex: String, +} + +struct Case { + manifest: Manifest, + grovedb_proof: Vec, + signature: Vec, + quorum_pubkey: [u8; 48], +} + +fn load_case(name: &str) -> Case { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../rs-drive-proof-verifier/tests/vectors") + .join(name); + let read = |file: &str| { + std::fs::read_to_string(dir.join(file)) + .unwrap_or_else(|e| panic!("read corpus file {name}/{file}: {e}")) + }; + Case { + manifest: serde_json::from_str(&read("manifest.json")).expect("corpus manifest"), + grovedb_proof: hex_vec(read("proof.hex").trim()), + signature: hex_vec(read("signature.hex").trim()), + quorum_pubkey: hex_vec(read("quorum_pubkey.hex").trim()) + .try_into() + .expect("48-byte quorum key"), + } +} + +impl Case { + fn proof(&self) -> Proof { + Proof { + grovedb_proof: self.grovedb_proof.clone(), + quorum_hash: hex_vec(&self.manifest.proof_meta.quorum_hash_hex), + signature: self.signature.clone(), + round: self.manifest.proof_meta.round, + block_id_hash: hex_vec(&self.manifest.proof_meta.block_id_hash_hex), + quorum_type: self.manifest.proof_meta.quorum_type, + } + } + + fn metadata(&self) -> ResponseMetadata { + ResponseMetadata { + height: self.manifest.block.height, + core_chain_locked_height: self.manifest.block.core_chain_locked_height, + epoch: u32::from(self.manifest.block.epoch), + time_ms: self.manifest.block.time_ms, + protocol_version: self.manifest.block.protocol_version, + chain_id: self.manifest.block.chain_id.clone(), + } + } + + fn identity_id(&self) -> Vec { + hex_vec( + self.manifest.request["identity_id"] + .as_str() + .expect("identity_id"), + ) + } + + fn contract_id(&self) -> Vec { + hex_vec( + self.manifest.request["contract_id"] + .as_str() + .expect("contract_id"), + ) + } + + fn quorum_hash(&self) -> [u8; 32] { + hex32(&self.manifest.proof_meta.quorum_hash_hex) + } +} + +// --- mock transport wiring --------------------------------------------------- + +fn nonce_request(case: &Case) -> proto::GetIdentityContractNonceRequest { + proto::GetIdentityContractNonceRequest { + version: Some(proto::get_identity_contract_nonce_request::Version::V0( + proto::get_identity_contract_nonce_request::GetIdentityContractNonceRequestV0 { + identity_id: case.identity_id(), + contract_id: case.contract_id(), + prove: true, + }, + )), + } +} + +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), + ), + }, + )), + } +} + +/// Writes one canned (request, response) pair into a fresh dump directory +/// the mock SDK loads at build time, and installs that SDK on the client. +fn install_mock_sdk(client: &Client, request: &R, response: R::Response) +where + R: TransportRequest, + R::Response: Clone, +{ + // Tests run in parallel; a wall-clock name can collide between threads + // and 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 result = Ok(ExecutionResponse { + inner: response, + retries: 0, + address: "http://127.0.0.1:1".parse().expect("address"), + }); + let dump = DumpData::new(request, &result); + dump.save(&dir.join(dump.filename().expect("dump file name"))) + .expect("save mock expectation"); + // Same freshness configuration as `Client::set_endpoints`, seeded from + // the client's watermark so a rebuilt SDK carries it forward. + let sdk: Sdk = SdkBuilder::new_mock() + .with_network(fixture_context().network) + .with_context_provider(std::sync::Arc::clone(client.provider())) + .with_height_tolerance(Some(3)) + .with_trusted_initial_height(client.last_seen_height()) + .with_dump_dir(&dir) + .build() + .expect("mock sdk"); + // The expectation is in memory now; the dump dir was only its carrier. + let _ = std::fs::remove_dir_all(&dir); + client.set_sdk(sdk).expect("install mock sdk"); +} + +fn nonce_client(case: &Case, metadata: ResponseMetadata) -> Client { + let client = mock_client(); + push_quorum_key(&client, case.quorum_hash(), case.quorum_pubkey); + install_mock_sdk( + &client, + &nonce_request(case), + nonce_response(case.proof(), metadata), + ); + client +} + +fn get_nonce( + client: &Client, + case: &Case, +) -> Result<(Option, dash_platform_cxx::types::Meta), String> { + queries::get_identity_contract_nonce(client, &case.identity_id(), &case.contract_id()) +} + +// --- tests ------------------------------------------------------------------ + +#[test] +fn identity_nonce_verifies_through_the_sdk_end_to_end() { + let case = load_case("identity-contract-nonce"); + let client = nonce_client(&case, case.metadata()); + let (nonce, meta) = get_nonce(&client, &case).expect("nonce verifies"); + assert_eq!(nonce, case.manifest.expected["nonce"].as_u64()); + assert_eq!(meta.height, case.manifest.block.height); + assert_eq!( + meta.core_chain_locked_height, + case.manifest.block.core_chain_locked_height + ); + assert_eq!(meta.time_ms, case.manifest.block.time_ms); + assert_eq!(meta.protocol_version, case.manifest.block.protocol_version); + assert_eq!(meta.chain_id, CHAIN_ID); +} + +#[test] +fn unknown_quorum_key_is_rejected_by_the_sdk() { + let case = load_case("identity-contract-nonce"); + let client = mock_client(); + // No quorum key pushed: the SDK's verifier must fail the lookup. + install_mock_sdk( + &client, + &nonce_request(&case), + nonce_response(case.proof(), case.metadata()), + ); + let err = get_nonce(&client, &case).unwrap_err(); + assert!(err.contains("no locally known Platform quorum"), "{err}"); +} + +#[test] +fn non_platform_quorum_type_is_rejected() { + let case = load_case("identity-contract-nonce"); + let client = mock_client(); + push_quorum_key(&client, case.quorum_hash(), case.quorum_pubkey); + let mut proof = case.proof(); + proof.quorum_type += 1; + install_mock_sdk( + &client, + &nonce_request(&case), + nonce_response(proof, case.metadata()), + ); + let err = get_nonce(&client, &case).unwrap_err(); + assert!(err.contains("quorum type"), "{err}"); +} + +#[test] +fn tampered_signature_is_rejected() { + let case = load_case("identity-contract-nonce"); + let client = mock_client(); + push_quorum_key(&client, case.quorum_hash(), case.quorum_pubkey); + let mut proof = case.proof(); + proof.signature[10] ^= 0x01; + install_mock_sdk( + &client, + &nonce_request(&case), + nonce_response(proof, case.metadata()), + ); + assert!(get_nonce(&client, &case).is_err()); +} + +#[test] +fn response_signed_for_another_chain_is_rejected() { + // A validly signed response from a network with a different Tenderdash + // chain id is refused by the client after the SDK verified it. + let case = load_case("identity-contract-nonce"); + let client = mock_client(); + let mut other_chain = fixture_context(); + other_chain.tenderdash_chain_id = "dash-mainnet".to_string(); + client.set_context(other_chain).expect("context"); + push_quorum_key(&client, case.quorum_hash(), case.quorum_pubkey); + install_mock_sdk( + &client, + &nonce_request(&case), + nonce_response(case.proof(), case.metadata()), + ); + let err = get_nonce(&client, &case).unwrap_err(); + assert!(err.contains("signed for tenderdash chain"), "{err}"); +} + +#[test] +fn signed_core_height_far_behind_the_local_chainlock_is_stale() { + let case = load_case("identity-contract-nonce"); + let client = nonce_client(&case, case.metadata()); + let signed = case.manifest.block.core_chain_locked_height; + // Exactly at the lag bound is still accepted... + client.set_core_chain_locked_height(signed + MAX_CORE_CHAINLOCK_LAG as u32); + get_nonce(&client, &case).expect("within the lag bound"); + // ...one block past it is stale. + let client = nonce_client(&case, case.metadata()); + client.set_core_chain_locked_height(signed + MAX_CORE_CHAINLOCK_LAG as u32 + 1); + let err = get_nonce(&client, &case).unwrap_err(); + assert!(err.contains("stale platform proof"), "{err}"); +} + +#[test] +fn verified_height_watermark_survives_a_rebuilt_sdk() { + let case = load_case("identity-contract-nonce"); + let client = mock_client(); + push_quorum_key(&client, case.quorum_hash(), case.quorum_pubkey); + // A previously verified response put the watermark well above the + // corpus height (the corpus has a single signed height, so the earlier + // response is simulated at the post-verification hook). + let mut ahead = case.metadata(); + ahead.height += 10; + client.accept(&ahead).expect("earlier verified response"); + assert_eq!(client.last_seen_height(), ahead.height); + // A rebuilt SDK (new endpoint set) is seeded with that watermark, so a + // node serving the older corpus state is refused by the SDK's monotonic + // height check even though its proof verifies. + install_mock_sdk( + &client, + &nonce_request(&case), + nonce_response(case.proof(), case.metadata()), + ); + let err = get_nonce(&client, &case).unwrap_err(); + assert!(err.contains("outdated"), "{err}"); +} + +#[test] +fn sdk_types_are_reachable_for_direct_rust_callers() { + // The bridge is the C++ surface; Rust embedders can still drive the + // SDK directly through the client. Pins that `Client::run` accepts an + // SDK future and maps its error. + let case = load_case("identity-contract-nonce"); + let client = nonce_client(&case, case.metadata()); + let id = dash_sdk::platform::Identifier::from_bytes(&case.identity_id()).expect("id"); + let contract = dash_sdk::platform::Identifier::from_bytes(&case.contract_id()).expect("id"); + let (nonce, _) = client + .run(move |sdk| async move { + IdentityContractNonceFetcher::fetch_with_metadata(&sdk, (id, contract), None).await + }) + .expect("direct sdk call"); + assert_eq!(nonce.map(|n| n.0), case.manifest.expected["nonce"].as_u64()); +} + +#[test] +fn queries_without_endpoints_fail_cleanly() { + let client = Client::new(); + client.set_context(fixture_context()).expect("context"); + let err = queries::get_identity(&client, &[0u8; 32]).unwrap_err(); + assert!(err.contains("set_endpoints"), "{err}"); + let err = queries::resolve_name(&client, "alice").unwrap_err(); + assert!(err.contains("set_endpoints"), "{err}"); +} + +#[test] +fn bad_inputs_are_refused_before_any_request() { + let client = mock_client(); + assert!(queries::get_identity(&client, &[0u8; 31]).is_err()); + assert!(queries::get_identity_by_pubkey_hash(&client, &[0u8; 19]).is_err()); + assert!(queries::get_identity_contract_nonce(&client, &[0u8; 32], &[0u8; 33]).is_err()); + assert!(queries::broadcast_state_transition(&client, &vec![0u8; 100 * 1024 + 1]).is_err()); +} + +#[test] +fn endpoints_are_validated_and_unchanged_sets_are_ignored() { + let client = Client::new(); + assert!( + client + .set_endpoints(vec!["https://1.2.3.4:1443".into()]) + .is_err(), + "no context yet" + ); + client.set_context(fixture_context()).expect("context"); + assert!(client.set_endpoints(Vec::new()).is_err(), "empty set"); + assert!( + client.set_endpoints(vec!["not a uri".into()]).is_err(), + "bad uri" + ); + client + .set_endpoints(vec!["https://1.2.3.4:1443".into()]) + .expect("valid endpoint"); + client + .set_endpoints(vec!["https://1.2.3.4:1443".into()]) + .expect("same set is a no-op"); + client.shutdown(); + client.shutdown(); +} diff --git a/packages/rs-platform-cxx/tests/signing.rs b/packages/rs-platform-cxx/tests/signing.rs new file mode 100644 index 00000000000..e25fe0c1786 --- /dev/null +++ b/packages/rs-platform-cxx/tests/signing.rs @@ -0,0 +1,490 @@ +// 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 builder tests against the rs-dpp-generated fixtures in +//! `test_data/dpp_st_vectors.json` (Platform v4.0.0, protocol +//! version 12). The vectors carry full serialized transitions built from +//! deterministic inputs (fixed keys, entropy, RFC6979 ECDSA), so the +//! builders are checked byte-for-byte. +//! +//! The signing callback stands in for the C++ WalletSigner: it receives the +//! key id plus the double-SHA256 digest of the signable bytes and answers +//! with `dashcore::signer::sign_hash`, which is exactly what +//! `signer::sign(signable_bytes, key)` would produce. + +use std::sync::Mutex; + +use dash_platform_cxx::st::{self, AssetLockProofInput, NewIdentityKey, ASSET_LOCK_KEY_ID}; +use dash_platform_cxx::types::KeyInfo; +use dpp::dashcore::hashes::{sha256, Hash}; +use dpp::dashcore::signer as dash_signer; +use platform_version::version::PlatformVersion; +use serde_json::Value; + +fn hexv(hex: &str) -> Vec { + hex::decode(hex).expect("bad hex in test vector") +} + +/// The vectors were generated at Platform v4.0.0 / protocol version 12; +/// build under that version so the wire format matches the pinned bytes. +fn version() -> &'static PlatformVersion { + PlatformVersion::get(12).expect("protocol version 12") +} + +fn hex32(hex: &str) -> [u8; 32] { + hexv(hex).try_into().expect("expected 32 bytes") +} + +fn vectors() -> Value { + let doc: Value = serde_json::from_str(include_str!("../test_data/dpp_st_vectors.json")) + .expect("parse dpp_st_vectors.json"); + assert_eq!(doc["platform_repo_tag"].as_str(), Some("v4.0.0")); + doc +} + +/// Test signer: routes key ids to the fixed vector keys and records every +/// digest it was asked to sign. +struct TestSigner { + master_sk: [u8; 32], + high_sk: [u8; 32], + asset_lock_sk: [u8; 32], + digests: Mutex>, +} + +impl TestSigner { + fn from_vectors(doc: &Value) -> Self { + TestSigner { + master_sk: hex32(doc["keys"]["master"]["private_key_hex"].as_str().unwrap()), + high_sk: hex32(doc["keys"]["high"]["private_key_hex"].as_str().unwrap()), + asset_lock_sk: hex32( + doc["keys"]["asset_lock"]["private_key_hex"] + .as_str() + .unwrap(), + ), + digests: Mutex::new(Vec::new()), + } + } + + fn sign(&self, key_id: u32, digest: [u8; 32]) -> Option> { + self.digests.lock().unwrap().push((key_id, digest)); + let sk = match key_id { + 0 => self.master_sk, + 1 => self.high_sk, + ASSET_LOCK_KEY_ID => self.asset_lock_sk, + _ => return None, + }; + dash_signer::sign_hash(&digest, &sk) + .ok() + .map(|s| s.to_vec()) + } +} + +fn high_key(doc: &Value) -> KeyInfo { + KeyInfo { + id: 1, + purpose: 0, // AUTHENTICATION + security_level: 2, // HIGH + key_type: 0, // ECDSA_SECP256K1 + read_only: false, + data: hexv(doc["keys"]["high"]["public_key_hex"].as_str().unwrap()), + disabled_at: None, + } +} + +fn check_built( + built: &dash_platform_cxx::types::BuiltTransition, + vector: &Value, + signer: &TestSigner, + expected_key_id: u32, +) { + assert_eq!( + hex::encode(&built.bytes), + vector["serialized_hex"].as_str().unwrap(), + "serialized transition bytes" + ); + assert_eq!( + built.hash, + sha256::Hash::hash(&built.bytes).to_byte_array(), + "hash must be single sha256 of the bytes" + ); + let expected_digest = hex32(vector["digest_hex"].as_str().unwrap()); + let digests = signer.digests.lock().unwrap(); + assert!( + digests.contains(&(expected_key_id, expected_digest)), + "signer must be asked for the vector digest with key id {expected_key_id}; got {digests:?}" + ); +} + +#[test] +fn dpns_preorder() { + let doc = vectors(); + let vector = &doc["dpns_preorder"]; + let signer = TestSigner::from_vectors(&doc); + let built = st::build_dpns_preorder( + version(), + &hexv(vector["owner_id_hex"].as_str().unwrap()), + vector["identity_contract_nonce"].as_u64().unwrap(), + vector["label"].as_str().unwrap(), + &hexv(vector["salt_hex"].as_str().unwrap()), + &high_key(&doc), + &|key_id, digest| signer.sign(key_id, digest), + ) + .expect("build DPNS preorder"); + check_built(&built, vector, &signer, 1); +} + +fn build_domain( + doc: &Value, + vector: &Value, + signer: &TestSigner, +) -> dash_platform_cxx::types::BuiltTransition { + st::build_dpns_domain( + version(), + &hexv(vector["owner_id_hex"].as_str().unwrap()), + vector["identity_contract_nonce"].as_u64().unwrap(), + vector["label"].as_str().unwrap(), + vector["normalized_label"].as_str().unwrap(), + "dash", + &hexv(vector["salt_hex"].as_str().unwrap()), + &high_key(doc), + &|key_id, digest| signer.sign(key_id, digest), + ) + .expect("build DPNS domain") +} + +#[test] +fn dpns_domain() { + let doc = vectors(); + let vector = &doc["dpns_domain"]; + assert_eq!(vector["contested"].as_bool(), Some(false)); + let signer = TestSigner::from_vectors(&doc); + let built = build_domain(&doc, vector, &signer); + check_built(&built, vector, &signer, 1); +} + +// A contested label must automatically attach the prefunded voting balance +// (rs-dpp computes it from the contested unique index of the domain type). +#[test] +fn dpns_domain_contested() { + let doc = vectors(); + let vector = &doc["dpns_domain_contested"]; + assert_eq!(vector["contested"].as_bool(), Some(true)); + let signer = TestSigner::from_vectors(&doc); + let built = build_domain(&doc, vector, &signer); + check_built(&built, vector, &signer, 1); +} + +#[test] +fn dpns_domain_rejects_bad_normalization() { + let doc = vectors(); + let signer = TestSigner::from_vectors(&doc); + let err = st::build_dpns_domain( + version(), + &[0x11; 32], + 1, + "Alice", + "alice", // wrong: o->0, l/i->1 normalization gives "a11ce" + "dash", + &[0x55; 32], + &high_key(&doc), + &|key_id, digest| signer.sign(key_id, digest), + ) + .unwrap_err(); + assert!(err.contains("normalized label"), "{err}"); +} + +#[test] +fn dashpay_profile_create() { + let doc = vectors(); + let vector = &doc["dashpay_profile_create"]; + let signer = TestSigner::from_vectors(&doc); + let built = st::build_profile( + version(), + &hexv(vector["owner_id_hex"].as_str().unwrap()), + vector["identity_contract_nonce"].as_u64().unwrap(), + vector["display_name"].as_str().unwrap(), + vector["public_message"].as_str().unwrap(), + vector["avatar_url"].as_str().unwrap(), + &hexv(vector["avatar_hash_hex"].as_str().unwrap()), + &hexv(vector["avatar_fingerprint_hex"].as_str().unwrap()), + 1, + None, + &hexv(vector["entropy_hex"].as_str().unwrap()), + &high_key(&doc), + &|key_id, digest| signer.sign(key_id, digest), + ) + .expect("build profile create"); + check_built(&built, vector, &signer, 1); +} + +#[test] +fn dashpay_profile_replace() { + let doc = vectors(); + let create = &doc["dashpay_profile_create"]; + let vector = &doc["dashpay_profile_replace"]; + let signer = TestSigner::from_vectors(&doc); + // The replace fixture carries the create fixture's profile fields with + // an updated publicMessage (see the serialized property map). + let built = st::build_profile( + version(), + &hexv(vector["owner_id_hex"].as_str().unwrap()), + vector["identity_contract_nonce"].as_u64().unwrap(), + create["display_name"].as_str().unwrap(), + vector["public_message"].as_str().unwrap(), + create["avatar_url"].as_str().unwrap(), + &hexv(create["avatar_hash_hex"].as_str().unwrap()), + &hexv(create["avatar_fingerprint_hex"].as_str().unwrap()), + vector["revision"].as_u64().unwrap(), + Some(&hexv(vector["document_id_hex"].as_str().unwrap())), + &[0u8; 32], // entropy is unused for replacements + &high_key(&doc), + &|key_id, digest| signer.sign(key_id, digest), + ) + .expect("build profile replace"); + check_built(&built, vector, &signer, 1); +} + +#[test] +fn dashpay_contact_request() { + let doc = vectors(); + let vector = &doc["dashpay_contact_request"]; + let signer = TestSigner::from_vectors(&doc); + let built = st::build_contact_request( + version(), + &hexv(vector["owner_id_hex"].as_str().unwrap()), + vector["identity_contract_nonce"].as_u64().unwrap(), + &hexv(vector["to_user_id_hex"].as_str().unwrap()), + &hexv(vector["encrypted_public_key_hex"].as_str().unwrap()), + vector["sender_key_index"].as_u64().unwrap() as u32, + vector["recipient_key_index"].as_u64().unwrap() as u32, + vector["account_reference"].as_u64().unwrap() as u32, + &hexv(vector["encrypted_account_label_hex"].as_str().unwrap()), + &hexv(vector["entropy_hex"].as_str().unwrap()), + &high_key(&doc), + &|key_id, digest| signer.sign(key_id, digest), + ) + .expect("build contact request"); + check_built(&built, vector, &signer, 1); +} + +fn identity_keys(doc: &Value) -> Vec { + vec![ + NewIdentityKey { + id: 0, + purpose: 0, // AUTHENTICATION + security_level: 0, // MASTER + pubkey: hexv(doc["keys"]["master"]["public_key_hex"].as_str().unwrap()), + }, + NewIdentityKey { + id: 1, + purpose: 0, + security_level: 2, // HIGH + pubkey: hexv(doc["keys"]["high"]["public_key_hex"].as_str().unwrap()), + }, + ] +} + +fn check_identity_create( + built: &dash_platform_cxx::types::BuiltTransition, + vector: &Value, + signer: &TestSigner, +) { + assert_eq!( + hex::encode(&built.bytes), + vector["serialized_hex"].as_str().unwrap(), + "serialized identity create bytes" + ); + let expected_digest = hex32(vector["digest_hex"].as_str().unwrap()); + let digests = signer.digests.lock().unwrap().clone(); + // Every identity key and the asset-lock key sign the same digest. + for key_id in [0, 1, ASSET_LOCK_KEY_ID] { + assert!( + digests.contains(&(key_id, expected_digest)), + "key {key_id} must sign the vector digest" + ); + } + + // Round trip: the built bytes must deserialize back into an + // IdentityCreate transition with signatures present. + use dpp::serialization::PlatformDeserializable; + use dpp::state_transition::StateTransition; + let deserialized = + StateTransition::deserialize_from_bytes(&built.bytes).expect("round-trip deserialize"); + match &deserialized { + StateTransition::IdentityCreate(_) => {} + other => panic!("expected IdentityCreate, got {other:?}"), + } + let reserialized = { + use dpp::serialization::PlatformSerializable; + deserialized.serialize_to_bytes().expect("re-serialize") + }; + assert_eq!(reserialized, built.bytes, "round trip must be stable"); + assert!( + deserialized + .signature() + .map(|signature| !signature.is_empty()) + .unwrap_or(false), + "outer signature must be present" + ); +} + +#[test] +fn identity_create_instant() { + let doc = vectors(); + let vector = &doc["identity_create_instant"]; + let signer = TestSigner::from_vectors(&doc); + let built = st::build_identity_create( + version(), + AssetLockProofInput::Instant { + transaction: hexv(vector["transaction_hex"].as_str().unwrap()), + instant_lock: hexv(vector["instant_lock_hex"].as_str().unwrap()), + output_index: vector["output_index"].as_u64().unwrap() as u32, + }, + &identity_keys(&doc), + &|key_id, digest| signer.sign(key_id, digest), + ) + .expect("build identity create (instant)"); + check_identity_create(&built, vector, &signer); +} + +#[test] +fn identity_create_chain() { + let doc = vectors(); + let vector = &doc["identity_create_chain"]; + let signer = TestSigner::from_vectors(&doc); + let built = st::build_identity_create( + version(), + AssetLockProofInput::Chain { + core_chain_locked_height: vector["core_chain_locked_height"].as_u64().unwrap() as u32, + out_point: hexv(vector["out_point_hex"].as_str().unwrap()) + .try_into() + .expect("36-byte outpoint"), + }, + &identity_keys(&doc), + &|key_id, digest| signer.sign(key_id, digest), + ) + .expect("build identity create (chain)"); + check_identity_create(&built, vector, &signer); +} + +// Keys must arrive at the wire sorted by id with no duplicates, regardless +// of input order. +#[test] +fn identity_create_sorts_and_rejects_duplicate_keys() { + let doc = vectors(); + let vector = &doc["identity_create_chain"]; + let signer = TestSigner::from_vectors(&doc); + let proof = || AssetLockProofInput::Chain { + core_chain_locked_height: vector["core_chain_locked_height"].as_u64().unwrap() as u32, + out_point: hexv(vector["out_point_hex"].as_str().unwrap()) + .try_into() + .unwrap(), + }; + + let mut reversed = identity_keys(&doc); + reversed.reverse(); + let built = st::build_identity_create(version(), proof(), &reversed, &|key_id, digest| { + signer.sign(key_id, digest) + }) + .expect("reversed key order still builds"); + assert_eq!( + hex::encode(&built.bytes), + vector["serialized_hex"].as_str().unwrap() + ); + + let mut duplicated = identity_keys(&doc); + duplicated[1].id = 0; + let err = st::build_identity_create(version(), proof(), &duplicated, &|key_id, digest| { + signer.sign(key_id, digest) + }) + .unwrap_err(); + assert!(err.contains("duplicate"), "{err}"); +} + +#[test] +fn signer_failure_is_reported() { + let doc = vectors(); + let vector = &doc["dpns_preorder"]; + let err = st::build_dpns_preorder( + version(), + &hexv(vector["owner_id_hex"].as_str().unwrap()), + 2, + vector["label"].as_str().unwrap(), + &hexv(vector["salt_hex"].as_str().unwrap()), + &high_key(&doc), + &|_key_id, _digest| None, // wallet refuses + ) + .unwrap_err(); + assert!( + err.contains("signing failed") || err.contains("locked"), + "{err}" + ); +} + +/// The callback produces compact recoverable ECDSA signatures, so a key of +/// any other type must be refused before signing is attempted. +#[test] +fn non_ecdsa_key_is_refused() { + let doc = vectors(); + let vector = &doc["dpns_preorder"]; + let signer = TestSigner::from_vectors(&doc); + let mut key = high_key(&doc); + key.key_type = 1; // BLS12_381 + let err = st::build_dpns_preorder( + version(), + &hexv(vector["owner_id_hex"].as_str().unwrap()), + 1, + vector["label"].as_str().unwrap(), + &hexv(vector["salt_hex"].as_str().unwrap()), + &key, + &|key_id, digest| signer.sign(key_id, digest), + ) + .unwrap_err(); + assert!(err.contains("ECDSA"), "{err}"); +} + +/// A signer answering with something other than a 65-byte compact signature +/// is a wallet bug; it must surface as an error, not as a malformed +/// transition. +#[test] +fn wrong_signature_size_is_reported() { + let doc = vectors(); + let vector = &doc["dpns_preorder"]; + let err = st::build_dpns_preorder( + version(), + &hexv(vector["owner_id_hex"].as_str().unwrap()), + 1, + vector["label"].as_str().unwrap(), + &hexv(vector["salt_hex"].as_str().unwrap()), + &high_key(&doc), + &|_key_id, _digest| Some(vec![0u8; 64]), + ) + .unwrap_err(); + assert!(err.contains("signature size"), "{err}"); +} + +/// A label the DPNS contract pattern refuses fails before anything is +/// signed. +#[test] +fn invalid_dpns_label_is_refused_before_signing() { + let doc = vectors(); + let signer = TestSigner::from_vectors(&doc); + let err = st::build_dpns_domain( + version(), + &[0x11; 32], + 1, + "-bad", + "-bad", + "dash", + &[0x55; 32], + &high_key(&doc), + &|key_id, digest| signer.sign(key_id, digest), + ) + .unwrap_err(); + assert!(err.contains("label"), "{err}"); + assert!( + signer.digests.lock().unwrap().is_empty(), + "nothing must be signed" + ); +} diff --git a/packages/rs-sdk/README.md b/packages/rs-sdk/README.md index 63c60515dc9..5dd4d523547 100644 --- a/packages/rs-sdk/README.md +++ b/packages/rs-sdk/README.md @@ -47,12 +47,14 @@ You can see examples of mocking in [mock_fetch.rs](tests/fetch/mock_fetch.rs) an The query-building, wire-encoding, and proof-verification layers of this SDK live in the [`dash-platform-queries`](../dash-platform-queries) crate, which this crate depends on and re-exports at the historical paths. Embedders that -bring their own transport and trust context (Dash Core's platform GUI, block -explorers) can depend on `dash-platform-queries` + `drive-proof-verifier` +bring their own transport and trust context (block explorers, Electrum-style +servers) can depend on `dash-platform-queries` + `drive-proof-verifier` directly and get typed, proof-verified results without `rs-dapi-client` or tonic's native channel/TLS stack in their dependency tree. Shared generated types and context-provider utilities remain dependencies. See that crate's -README for details. +README for details. C++ applications that want the whole SDK, networking +included, with their own trust context and signing keys use +[`dash-platform-cxx`](../rs-platform-cxx). ## Examples