From b283a5f71644b592f86cd6a1ddf16834ac0edd43 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 8 Sep 2026 13:52:34 -0500 Subject: [PATCH] feat(sdk): add CXX bindings over dash-sdk for C++ embedders A C++ application that wants Dash Platform with its own trust context and signing keys had no supported path: rs-sdk-ffi is a C ABI shaped for mobile wallets that fetch quorum keys from a trusted HTTP service and hold private keys in-process. Dash Core's platform GUI needs the opposite: quorum keys from its locally synced LLMQ store, endpoints from its deterministic masternode list, signatures from its wallet, and no key or trust ever leaving the node. packages/rs-platform-cxx is a thin cxx bridge over dash-sdk. The embedder pushes endpoints, Platform quorum keys, its best ChainLock height and a digest-signing callback; the SDK owns query construction, transport (TLS to the evonodes), retries with address banning, proof verification and protocol-version tracking. Queries go through the SDK's Fetch/FetchMany so the rich query it built is what it verifies against; no wire request is ever reconstructed from bytes. On top of the SDK's signed-time window and monotonic Platform height, 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 ChainLock by more than 288 blocks. Every bridge entry point runs under catch_unwind, so a panic anywhere in the SDK surfaces as rust::Error rather than aborting the embedding process; the crate refuses to build under panic=abort. Document assembly is dash-platform-queries' pure DPNS/DashPay builders; signing is dpp's Signer over the callback, with dash-sdk's structure validation before serialization. Broadcast rejections carry DAPI's consensus error decoded through the SDK's error conversion. Tests replay drive-proof-verifier's proof-vector corpus through dash-sdk's mock transport (only the socket is mocked; the SDK runs the GroveDB replay and the BLS quorum check against the key pushed through the client) and cover unknown quorum key, wrong quorum type, tampered signature, foreign chain id, the ChainLock staleness floor, the height watermark surviving an SDK rebuild, cancellation and input bounds; builders are pinned byte for byte against rs-dpp-generated vectors; tests/cxx_smoke.cc links and runs from C++ in CI. Validated against live testnet from a release-built C++ driver: identity, nonce, DPNS resolve (registered and proven absent), names-of-identity, prefix search, profile, contact requests, contested vote state and a rejected broadcast all verify in 0.3 to 2 s; bit-flipped quorum keys fail at the BLS check. --- .../package-filters/rs-packages-direct.yml | 3 + .../rs-packages-no-workflows.yml | 11 + .github/package-filters/rs-packages.yml | 12 + .github/workflows/tests-rs-workspace.yml | 8 + Cargo.lock | 145 ++- Cargo.toml | 1 + packages/dash-platform-queries/README.md | 10 +- packages/rs-platform-cxx/Cargo.toml | 51 + packages/rs-platform-cxx/README.md | 104 ++ packages/rs-platform-cxx/build.rs | 61 + .../include/dash/platform/signer.h | 65 ++ packages/rs-platform-cxx/scripts/cxx-smoke.sh | 35 + packages/rs-platform-cxx/src/client.rs | 310 +++++ packages/rs-platform-cxx/src/decode.rs | 219 ++++ packages/rs-platform-cxx/src/lib.rs | 1028 +++++++++++++++++ packages/rs-platform-cxx/src/provider.rs | 209 ++++ packages/rs-platform-cxx/src/queries.rs | 422 +++++++ packages/rs-platform-cxx/src/st.rs | 638 ++++++++++ packages/rs-platform-cxx/src/types.rs | 126 ++ .../test_data/dpp_identity_vectors.json | 52 + .../test_data/dpp_st_vectors.json | 233 ++++ packages/rs-platform-cxx/tests/common/mod.rs | 69 ++ packages/rs-platform-cxx/tests/cxx_smoke.cc | 142 +++ packages/rs-platform-cxx/tests/decoders.rs | 189 +++ packages/rs-platform-cxx/tests/queries.rs | 392 +++++++ packages/rs-platform-cxx/tests/signing.rs | 490 ++++++++ packages/rs-sdk/README.md | 8 +- 27 files changed, 5021 insertions(+), 12 deletions(-) create mode 100644 packages/rs-platform-cxx/Cargo.toml create mode 100644 packages/rs-platform-cxx/README.md create mode 100644 packages/rs-platform-cxx/build.rs create mode 100644 packages/rs-platform-cxx/include/dash/platform/signer.h create mode 100755 packages/rs-platform-cxx/scripts/cxx-smoke.sh create mode 100644 packages/rs-platform-cxx/src/client.rs create mode 100644 packages/rs-platform-cxx/src/decode.rs create mode 100644 packages/rs-platform-cxx/src/lib.rs create mode 100644 packages/rs-platform-cxx/src/provider.rs create mode 100644 packages/rs-platform-cxx/src/queries.rs create mode 100644 packages/rs-platform-cxx/src/st.rs create mode 100644 packages/rs-platform-cxx/src/types.rs create mode 100644 packages/rs-platform-cxx/test_data/dpp_identity_vectors.json create mode 100644 packages/rs-platform-cxx/test_data/dpp_st_vectors.json create mode 100644 packages/rs-platform-cxx/tests/common/mod.rs create mode 100644 packages/rs-platform-cxx/tests/cxx_smoke.cc create mode 100644 packages/rs-platform-cxx/tests/decoders.rs create mode 100644 packages/rs-platform-cxx/tests/queries.rs create mode 100644 packages/rs-platform-cxx/tests/signing.rs 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