From b9451fd7eeba0801ca4ff5f49bacf470de364ac1 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 19:02:46 -0500 Subject: [PATCH] feat(sdk): add transport-free CXX bindings for C++ embedders packages/rs-platform-cxx (crate dash-platform-cxx) is the C++ embedding surface Dash Core's platform GUI consumes: proof verification over (request bytes, response bytes), DPP identity/document decoding, and state-transition construction with signing delegated to a C++ digest callback so private keys never cross the FFI. It is a thin cxx bridge over the workspace's own crates rather than a reimplementation: verification is drive-proof-verifier's FromProof impls (including the new request-driven FromProof), document assembly is dash-platform-queries' shared DPNS/DashPay builders, and signing implements dpp's Signer trait over the callback, exactly as rs-sdk-ffi does for Swift. Trust boundary, since every input byte comes from an untrusted node and GroveDB replay necessarily runs before the quorum signature check: every extern Rust entry point runs under catch_unwind (cxx turns Result::Err into rust::Error but a panic reaching its shim aborts the embedding process); request/response bytes are capped before prost decoding; set_context records the network's Platform LLMQ type and any proof naming another quorum type is refused before a key lookup, so keys the embedder pushes for other purposes can never sign Platform state; a response claiming an unknown protocol version is refused instead of verified under a guessed one; the signer refuses non-ECDSA key types it cannot produce signatures for; transitions are structure-validated (as dash-sdk does before broadcast) and built under the network's protocol version from set_context rather than this build's latest. Packaging follows the workspace's FFI crates: build.rs stages the generated bridge header, the cxx runtime header and the hand-written signer.h under target//include/, so consumers vendor from the workspace root and install that tree plus the static archive. There is no nested manifest or second lockfile. Tests replay drive-proof-verifier's proof-vector corpus directly (the crate's own copies were byte-identical) and cover tampered signature/quorum/block-id/grovedb bytes, every signed metadata field, non-Platform quorum type, unknown protocol version, request/proof identity mismatch, wrong-shape proof, oversized and garbage input; tests/cxx_smoke.cc links and runs against the staged interface from C++ in CI. --- .../package-filters/rs-packages-direct.yml | 3 + .../rs-packages-no-workflows.yml | 10 + .github/package-filters/rs-packages.yml | 11 + .github/workflows/tests-rs-workspace.yml | 9 +- Cargo.lock | 145 +++- Cargo.toml | 1 + packages/rs-platform-cxx/Cargo.toml | 45 + packages/rs-platform-cxx/README.md | 64 ++ packages/rs-platform-cxx/build.rs | 61 ++ .../include/dash/platform/signer.h | 65 ++ packages/rs-platform-cxx/scripts/cxx-smoke.sh | 33 + packages/rs-platform-cxx/src/decode.rs | 189 ++++ packages/rs-platform-cxx/src/lib.rs | 806 ++++++++++++++++++ packages/rs-platform-cxx/src/provider.rs | 221 +++++ packages/rs-platform-cxx/src/st.rs | 637 ++++++++++++++ packages/rs-platform-cxx/src/types.rs | 113 +++ packages/rs-platform-cxx/src/verify.rs | 257 ++++++ .../test_data/dpp_identity_vectors.json | 52 ++ .../test_data/dpp_st_vectors.json | 233 +++++ packages/rs-platform-cxx/tests/common/mod.rs | 156 ++++ packages/rs-platform-cxx/tests/cxx_smoke.cc | 111 +++ packages/rs-platform-cxx/tests/decoders.rs | 191 +++++ packages/rs-platform-cxx/tests/from_proof.rs | 444 ++++++++++ packages/rs-platform-cxx/tests/signing.rs | 494 +++++++++++ 24 files changed, 4345 insertions(+), 6 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/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/st.rs create mode 100644 packages/rs-platform-cxx/src/types.rs create mode 100644 packages/rs-platform-cxx/src/verify.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/from_proof.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 0ea1d150f9f..42edee40047 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -121,6 +121,9 @@ platform-encryption: dash-platform-queries: - packages/dash-platform-queries/** +dash-platform-cxx: + - packages/rs-platform-cxx/** + dash-sdk: - packages/rs-sdk/** diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index a607e75df8a..b7c3cd7dfd8 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -136,6 +136,16 @@ platform-encryption: &platform_encryption dash-platform-queries: &platform_queries - packages/dash-platform-queries/** +dash-platform-cxx: + - packages/rs-platform-cxx/** + - packages/rs-drive-proof-verifier/** + - *platform_queries + - *platform_query_wire + - *context_provider + - *dpp + - *drive + - *dapi_grpc + 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 c973b1f7b59..659ea16504e 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -162,6 +162,17 @@ dash-platform-queries: &platform_queries - .github/workflows/tests* - packages/dash-platform-queries/** +dash-platform-cxx: + - .github/workflows/tests* + - packages/rs-platform-cxx/** + - packages/rs-drive-proof-verifier/** + - *platform_queries + - *platform_query_wire + - *context_provider + - *dpp + - *drive + - *dapi_grpc + 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 f5f4bb7bce5..1e403767946 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -204,13 +204,14 @@ jobs: cargo check -p drive-proof-verifier --locked cargo check -p dash-platform-queries --locked cargo check -p platform-query-wire --locked + cargo check -p dash-platform-cxx --locked # Native graphs: assert the networking transport stack stays out. # `tonic` itself is present (dapi-grpc's generated client types) but # without its transport feature — which is exactly what the absence # of hyper/rustls/tower proves. tokio is deliberately NOT asserted # absent: dash-context-provider depends on dash-async, which uses it # on native targets, and that edge predates the queries-crate split. - for native_package in platform-query-wire drive-proof-verifier dash-platform-queries; do + for native_package in platform-query-wire drive-proof-verifier dash-platform-queries dash-platform-cxx; do for banned in hyper rustls tower; do if cargo tree -p "$native_package" -e normal -i "$banned" 2>/dev/null | grep -q .; then echo "::error::$banned leaked into $native_package's dependency tree" @@ -227,6 +228,11 @@ 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. + - 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: | @@ -346,6 +352,7 @@ jobs: --package platform-serialization \ --package dapi-grpc \ --package platform-query-wire \ + --package dash-platform-cxx \ --package json-schema-compatibility-validator \ --package dashpay-contract \ --package dpns-contract \ diff --git a/Cargo.lock b/Cargo.lock index 99bf79e01d0..e7b522b5750 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", + "ciborium", + "cxx", + "cxx-build", + "dapi-grpc", + "dash-context-provider", + "dash-platform-queries", + "dpp", + "drive-proof-verifier", + "futures", + "hex", + "platform-version", + "prost 0.14.4", + "serde", + "serde_json", +] + [[package]] name = "dash-platform-macros" version = "4.2.0-dev.8" @@ -4313,6 +4407,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" @@ -5595,8 +5698,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", @@ -5617,7 +5720,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", @@ -5630,7 +5733,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", @@ -6806,6 +6909,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" @@ -7484,6 +7593,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" @@ -7606,6 +7726,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" @@ -8422,6 +8551,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 0077561885a..86fadcfcf35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "packages/rs-dpp", "packages/rs-drive", "packages/rs-platform-query-wire", + "packages/rs-platform-cxx", "packages/rs-platform-value", "packages/rs-platform-serialization", "packages/rs-platform-serialization-derive", diff --git a/packages/rs-platform-cxx/Cargo.toml b/packages/rs-platform-cxx/Cargo.toml new file mode 100644 index 00000000000..cae604adbd3 --- /dev/null +++ b/packages/rs-platform-cxx/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "dash-platform-cxx" +version.workspace = true +authors = ["Dash Core Group "] +edition = "2021" +rust-version.workspace = true +license = "MIT" +description = "Transport-free CXX bindings for embedding Dash Platform" + +[lib] +name = "dash_platform_cxx" +crate-type = ["staticlib", "rlib"] + +[dependencies] +cxx = "1.0" +dapi-grpc = { path = "../dapi-grpc", default-features = false, features = [ + "platform", + "client", +] } +dash-context-provider = { path = "../rs-context-provider", default-features = false } +dash-platform-queries = { path = "../dash-platform-queries", default-features = false } +drive-proof-verifier = { path = "../rs-drive-proof-verifier", 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", +] } +platform-version = { path = "../rs-platform-version" } +prost = "0.14" +hex = "0.4" +futures = "0.3" +async-trait = "0.1" + +[build-dependencies] +cxx-build = "1.0" + +[dev-dependencies] +ciborium = "0.2.2" +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..7145587eee4 --- /dev/null +++ b/packages/rs-platform-cxx/README.md @@ -0,0 +1,64 @@ +# dash-platform-cxx + +Transport-free C++ embedding surface for Dash Platform: proof verification, +DPP decoding and state-transition construction for an application that owns +its own DAPI transport, quorum-key sync and private keys (Dash Core's +platform GUI is the first consumer). + +## What it is + +A thin [`cxx`](https://cxx.rs) bridge over the workspace's own crates: + +- verification is `drive-proof-verifier`'s `FromProof` impls, including the + request-driven `FromProof` that rebuilds a document + query from the wire bytes the transport actually sent; +- document assembly is `dash-platform-queries`' pure DPNS / DashPay builders, + the same functions `dash-sdk` uses; +- signing is dpp's own `Signer` trait implemented over a C++ digest callback + (`WalletSigner`, `include/dash/platform/signer.h`), so private keys never + cross the FFI; +- quorum keys and data contracts come from a `ContextProvider` the embedder + feeds from its locally synced LLMQ state; nothing here talks to the network. + +`cxx` rather than the workspace's usual cbindgen C ABI because the surface is +dominated by nested byte vectors (`Vec>` of documents, key lists) 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 request and response byte comes from an untrusted node. The GroveDB +replay necessarily runs before the quorum signature check (the root hash only +exists after replay), so: + +- inputs are capped at `verify::MAX_MESSAGE_BYTES` before decoding; +- a proof naming any quorum type other than the network's Platform type is + refused before its key is looked up (`set_context` records the type); +- every bridge entry point runs under `catch_unwind`: a panic anywhere in the + decoders is reported as a `rust::Error`, never a process abort. The crate + must therefore be built with `panic = "unwind"` (the default); +- the returned metadata (height, time, core height) is authenticated by the + signature; deciding whether it is *fresh enough* is the embedder's job. + +## 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`. +`tests/cxx_smoke.cc` is a link-and-run check of exactly that interface; CI +runs it through `scripts/cxx-smoke.sh`. 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..e208033f985 --- /dev/null +++ b/packages/rs-platform-cxx/scripts/cxx-smoke.sh @@ -0,0 +1,33 @@ +#!/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}" +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/decode.rs b/packages/rs-platform-cxx/src/decode.rs new file mode 100644 index 00000000000..87528c2f253 --- /dev/null +++ b/packages/rs-platform-cxx/src/decode.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. + +//! DPP decoders for C++ identity and document value types, built on the real +//! rs-dpp deserializers. + +use std::collections::BTreeMap; + +use dpp::data_contract::accessors::v0::DataContractV0Getters; +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::provider::context; +use crate::types::{ContactRequest, DpnsName, IdentityInfo, KeyInfo, Profile}; +use crate::verify::MAX_MESSAGE_BYTES; + +/// The decoders run on bytes the embedder took out of a verified response; +/// they are still bounded like every other input crossing the bridge. +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)) +} + +fn decode_document( + bytes: &[u8], + contract: SystemDataContract, + document_type_name: &str, +) -> Result { + use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; + check_size(bytes, document_type_name)?; + // Stored documents are decoded under the network's protocol version (the + // one they were proved under), as recorded by set_context. + let protocol_version = context()?.protocol_version; + let version = PlatformVersion::get(protocol_version) + .map_err(|e| format!("network protocol version {protocol_version} is unknown: {e}"))?; + 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 decode_dpns_domain(doc_bytes: &[u8]) -> Result { + let document = decode_document(doc_bytes, SystemDataContract::DPNS, "domain")?; + 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 decode_dashpay_profile(doc_bytes: &[u8]) -> Result { + let document = decode_document(doc_bytes, SystemDataContract::Dashpay, "profile")?; + 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 decode_contact_request(doc_bytes: &[u8]) -> Result { + let document = decode_document(doc_bytes, SystemDataContract::Dashpay, "contactRequest")?; + 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), + }) +} diff --git a/packages/rs-platform-cxx/src/lib.rs b/packages/rs-platform-cxx/src/lib.rs new file mode 100644 index 00000000000..fd72075a4c5 --- /dev/null +++ b/packages/rs-platform-cxx/src/lib.rs @@ -0,0 +1,806 @@ +// 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. + +//! Transport-free Dash Platform client internals exposed to C++ embedders. +//! +//! - `verify`: Drive/GroveDB proof verification over DAPI wire bytes; +//! - `decode`: DPP decoders for identities and DPNS/DashPay documents; +//! - `st`: state-transition construction with callback-based signing. +//! +//! The `#[cxx::bridge]` below exposes thin adapters over those modules. +//! Signing crosses the FFI as a digest callback (`WalletSigner`, +//! `dash/platform/signer.h`) so private keys never leave the embedder. +//! +//! Every `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 +//! inputs here are untrusted DAPI bytes, so a panic anywhere in the decoders +//! is reported as an error instead. (This only helps under `panic = "unwind"`; +//! the crate must not be built with `panic = "abort"`.) + +pub mod decode; +pub mod provider; +pub mod st; +pub mod types; +pub mod verify; + +use types::{BuiltTransition, KeyInfo}; + +#[allow(clippy::too_many_arguments)] +#[cxx::bridge(namespace = "platform_ffi")] +mod ffi { + /// A byte vector, wrapped because cxx shared structs cannot hold + /// Vec>. + #[derive(Clone)] + struct FfiBytes { + data: Vec, + } + + /// 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. + 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. + 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 document query result (serialized documents; empty means + /// proven no matches). + struct FfiVerifiedDocs { + documents: 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, + } + + /// 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" { + // --- Node-local verification context ---------------------------- + /// Sets the network ("main"/"test"/"regtest"/"devnet"), the LLMQ + /// type its Platform quorums use (proofs signed by any other type are + /// refused), the protocol version the network runs (state + /// transitions are built under it) and the Platform activation core + /// height (0 = unknown; only consulted by query paths that require + /// it). Clears any previously pushed quorum keys. + fn set_context( + network_id: &str, + platform_quorum_type: u32, + protocol_version: u32, + platform_activation_height: u32, + ) -> Result<()>; + /// Replaces the stored Platform quorum keys with `keys`. + fn update_quorum_keys(keys: Vec) -> Result<()>; + + // --- FromProof verification over (request, response) bytes ------ + fn verify_get_identity_nonce(request: &[u8], response: &[u8]) -> Result; + fn verify_get_identity_contract_nonce( + request: &[u8], + response: &[u8], + ) -> Result; + fn verify_get_identity(request: &[u8], response: &[u8]) -> Result; + fn verify_get_identity_by_pubkey_hash( + request: &[u8], + response: &[u8], + ) -> Result; + fn verify_get_documents(request: &[u8], response: &[u8]) -> Result; + fn verify_get_contested_vote_state( + request: &[u8], + response: &[u8], + ) -> Result; + + // --- DPP decoders ----------------------------------------------- + fn decode_identity(bytes: &[u8]) -> Result; + fn decode_identity_public_key(bytes: &[u8]) -> Result; + fn decode_dpns_domain(doc_bytes: &[u8]) -> Result; + fn decode_dashpay_profile(doc_bytes: &[u8]) -> Result; + fn decode_contact_request(doc_bytes: &[u8]) -> Result; + + // --- State transitions ------------------------------------------ + fn st_build_dpns_preorder( + 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( + 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( + 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( + 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( + is_instant: bool, + transaction: &[u8], + instant_lock: &[u8], + output_index: u32, + core_chain_locked_height: u32, + out_point: &[u8], + keys: Vec, + signer: &WalletSigner, + ) -> Result; + } +} + +// --------------------------------------------------------------------------- +// 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(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: identity + .as_ref() + .map(ffi_identity) + .unwrap_or_else(|| ffi::FfiIdentity { + id: Vec::new(), + balance: 0, + revision: 0, + keys: Vec::new(), + }), + meta: ffi_meta(meta), + } +} + +/// 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}")) + }) +} + +// --------------------------------------------------------------------------- +// Bridge implementations: verification context. +// --------------------------------------------------------------------------- + +fn set_context( + network_id: &str, + platform_quorum_type: u32, + protocol_version: u32, + platform_activation_height: u32, +) -> Result<(), String> { + guarded("set_context", || { + provider::set_context( + network_id, + platform_quorum_type, + protocol_version, + platform_activation_height, + ) + }) +} + +fn update_quorum_keys(keys: Vec) -> Result<(), String> { + guarded("update_quorum_keys", || update_quorum_keys_inner(keys)) +} + +fn update_quorum_keys_inner(keys: Vec) -> Result<(), String> { + let keys = keys + .into_iter() + .map(|key| { + Ok(provider::QuorumKey { + quorum_hash: types::id32(&key.quorum_hash, "quorum hash")?, + public_key: key.pubkey.as_slice().try_into().map_err(|_| { + format!( + "quorum public key must be 48 bytes, got {}", + key.pubkey.len() + ) + })?, + }) + }) + .collect::, String>>()?; + provider::update_quorum_keys(keys) +} + +// --------------------------------------------------------------------------- +// Bridge implementations: FromProof verification. +// --------------------------------------------------------------------------- + +fn verify_get_identity_nonce( + request: &[u8], + response: &[u8], +) -> Result { + guarded("verify_get_identity_nonce", || { + verify::verify_get_identity_nonce(request, response).map(ffi_verified_u64) + }) +} + +fn verify_get_identity_contract_nonce( + request: &[u8], + response: &[u8], +) -> Result { + guarded("verify_get_identity_contract_nonce", || { + verify::verify_get_identity_contract_nonce(request, response).map(ffi_verified_u64) + }) +} + +fn verify_get_identity( + request: &[u8], + response: &[u8], +) -> Result { + guarded("verify_get_identity", || { + verify::verify_get_identity(request, response).map(ffi_verified_identity) + }) +} + +fn verify_get_identity_by_pubkey_hash( + request: &[u8], + response: &[u8], +) -> Result { + guarded("verify_get_identity_by_pubkey_hash", || { + verify::verify_get_identity_by_pubkey_hash(request, response).map(ffi_verified_identity) + }) +} + +fn verify_get_documents(request: &[u8], response: &[u8]) -> Result { + guarded("verify_get_documents", || { + let (documents, meta) = verify::verify_get_documents(request, response)?; + Ok(ffi::FfiVerifiedDocs { + documents: documents + .into_iter() + .map(|data| ffi::FfiBytes { data }) + .collect(), + meta: ffi_meta(meta), + }) + }) +} + +fn verify_get_contested_vote_state( + request: &[u8], + response: &[u8], +) -> Result { + guarded("verify_get_contested_vote_state", || { + verify_get_contested_vote_state_inner(request, response) + }) +} + +fn verify_get_contested_vote_state_inner( + request: &[u8], + response: &[u8], +) -> Result { + let (state, meta) = verify::verify_get_contested_vote_state(request, response)?; + Ok(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), + }) +} + +fn decode_identity(bytes: &[u8]) -> Result { + guarded("decode_identity", || { + decode::decode_identity(bytes).map(|identity| ffi_identity(&identity)) + }) +} + +fn decode_identity_public_key(bytes: &[u8]) -> Result { + guarded("decode_identity_public_key", || { + decode::decode_identity_public_key(bytes).map(|key| ffi_key(&key)) + }) +} + +fn decode_dpns_domain(doc_bytes: &[u8]) -> Result { + guarded("decode_dpns_domain", || decode_dpns_domain_inner(doc_bytes)) +} + +fn decode_dpns_domain_inner(doc_bytes: &[u8]) -> Result { + let name = decode::decode_dpns_domain(doc_bytes)?; + Ok(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 decode_dashpay_profile(doc_bytes: &[u8]) -> Result { + guarded("decode_dashpay_profile", || { + decode_dashpay_profile_inner(doc_bytes) + }) +} + +fn decode_dashpay_profile_inner(doc_bytes: &[u8]) -> Result { + let profile = decode::decode_dashpay_profile(doc_bytes)?; + Ok(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 decode_contact_request(doc_bytes: &[u8]) -> Result { + guarded("decode_contact_request", || { + decode_contact_request_inner(doc_bytes) + }) +} + +fn decode_contact_request_inner(doc_bytes: &[u8]) -> Result { + let request = decode::decode_contact_request(doc_bytes)?; + Ok(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(), + }) +} + +/// 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(()) +} + +fn st_build_dpns_preorder( + identity_id: &[u8], + identity_contract_nonce: u64, + label: &str, + preorder_salt: &[u8], + signature_public_key_id: u32, + key: ffi::FfiIdentityKey, + signer: &ffi::WalletSigner, +) -> Result { + guarded("st_build_dpns_preorder", || { + 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); + st::build_dpns_preorder( + identity_id, + identity_contract_nonce, + label, + preorder_salt, + &key_info(&key), + &sign_fn, + ) + .map(ffi_built) + }) +} + +#[allow(clippy::too_many_arguments)] +fn st_build_dpns_domain( + 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 { + guarded("st_build_dpns_domain", || { + 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); + st::build_dpns_domain( + identity_id, + identity_contract_nonce, + label, + normalized_label, + parent_domain, + preorder_salt, + &key_info(&key), + &sign_fn, + ) + .map(ffi_built) + }) +} + +#[allow(clippy::too_many_arguments)] +fn st_build_profile( + 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 { + guarded("st_build_profile", || { + 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); + st::build_profile( + 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_info(&key), + &sign_fn, + ) + .map(ffi_built) + }) +} + +#[allow(clippy::too_many_arguments)] +fn st_build_contact_request( + 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 { + guarded("st_build_contact_request", || { + 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); + st::build_contact_request( + identity_id, + identity_contract_nonce, + to_user_id, + encrypted_public_key, + sender_key_index, + recipient_key_index, + account_reference, + encrypted_account_label, + entropy, + &key_info(&key), + &sign_fn, + ) + .map(ffi_built) + }) +} + +#[allow(clippy::too_many_arguments)] +fn st_build_identity_create( + 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: out_point + .try_into() + .map_err(|_| format!("outpoint must be 36 bytes, got {}", out_point.len()))?, + } + }; + 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(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..d3e25fb3d19 --- /dev/null +++ b/packages/rs-platform-cxx/src/provider.rs @@ -0,0 +1,221 @@ +// 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. +//! +//! `FromProof` verification (drive-proof-verifier) 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. +//! Proof verification therefore never performs network fetches. +//! +//! The provider is process-global and supports one Platform network at a time. + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock, 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 reverse of +/// the embedding application's internal hash order; the C++ adapter converts). +pub struct QuorumKey { + pub quorum_hash: [u8; 32], + pub public_key: [u8; 48], +} + +/// The verification context the embedder installs before verifying anything. +#[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, + /// Protocol version the network runs; state transitions are built under + /// it so their wire format is what the network accepts. + 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>, +} + +/// Process-global provider instance. +pub struct LocalContextProvider { + state: RwLock, +} + +static PROVIDER: OnceLock = OnceLock::new(); + +/// The process-global provider handed to every FromProof call. +pub fn provider() -> &'static LocalContextProvider { + PROVIDER.get_or_init(|| LocalContextProvider { + state: RwLock::new(State::default()), + }) +} + +// 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` FromProof expects. +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:?}")), + } +} + +/// Installs the network, the LLMQ type its Platform quorums use, the +/// protocol version it runs, and the Platform activation core height (0 = +/// unknown). Replaces any previous context and drops the stored quorum keys, +/// which belonged to it. +pub fn set_context( + network_id: &str, + platform_quorum_type: u32, + protocol_version: u32, + platform_activation_height: u32, +) -> Result<(), String> { + let network = parse_network(network_id)?; + PlatformVersion::get(protocol_version).map_err(|e| { + format!("protocol version {protocol_version} is unknown to this build: {e}") + })?; + let mut state = write(&provider().state); + state.context = Some(Context { + network, + platform_quorum_type, + protocol_version, + platform_activation_height, + }); + state.quorum_keys.clear(); + Ok(()) +} + +/// The context set via [`set_context`]. +pub fn context() -> Result { + read(&provider().state) + .context + .clone() + .ok_or_else(|| "platform bridge context not initialized (set_context)".to_string()) +} + +/// Replaces the stored Platform quorum keys with `keys`. The C++ client +/// 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(keys: Vec) -> Result<(), String> { + let mut state = write(&provider().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/st.rs b/packages/rs-platform-cxx/src/st.rs new file mode 100644 index 00000000000..73935e844d1 --- /dev/null +++ b/packages/rs-platform-cxx/src/st.rs @@ -0,0 +1,637 @@ +// 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::provider::context; + +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, + })) +} + +/// The platform version transitions are built under: the one the connected +/// network runs, as recorded by `set_context`, so the wire format matches +/// what that network accepts rather than whatever this build calls latest. +fn platform_version() -> Result<&'static PlatformVersion, String> { + let protocol_version = context()?.protocol_version; + PlatformVersion::get(protocol_version) + .map_err(|e| format!("network protocol version {protocol_version} is unknown: {e}")) +} + +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 }) +} + +#[allow(clippy::too_many_arguments)] +/// Builds and signs a single-document create batch transition from an +/// assembled document. dpp's builder refuses an entropy that does not derive +/// the document id (Drive would reject it after the nonce bump), and the +/// properties are sanitized for the document type first, as `dash-sdk`'s +/// put-document path does. +fn build_document_create_transition( + contract: &DataContract, + document_type_name: &str, + mut document: Document, + identity_contract_nonce: u64, + entropy: [u8; 32], + 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 = 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, + ), + ) + .map_err(|e| format!("unable to build {document_type_name} create 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_create_transition( + &contract, + document_type_name, + document, + identity_contract_nonce, + 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( + identity_id: &[u8], + identity_contract_nonce: u64, + label: &str, + preorder_salt: &[u8], + key: &KeyInfo, + sign_fn: SignFn<'_>, +) -> Result { + let version = platform_version()?; + 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_create_transition( + &contract, + "preorder", + preorder, + identity_contract_nonce, + 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( + 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 version = platform_version()?; + 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_create_transition( + &contract, + "domain", + domain, + identity_contract_nonce, + salt, + key, + sign_fn, + version, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_profile( + 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 version = platform_version()?; + 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(); + if !display_name.is_empty() { + properties.insert( + "displayName".to_string(), + Value::Text(display_name.to_string()), + ); + } + if !public_message.is_empty() { + properties.insert( + "publicMessage".to_string(), + Value::Text(public_message.to_string()), + ); + } + if !avatar_url.is_empty() { + properties.insert("avatarUrl".to_string(), Value::Text(avatar_url.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_type = contract + .document_type_for_name("profile") + .map_err(|e| format!("unknown document type profile: {e}"))?; + let mut 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() + }); + document_type.sanitize_document_properties(document.properties_mut()); + let identity_key = identity_key_from_info(key)?; + let signer = CallbackSigner { sign_fn }; + let state_transition = futures::executor::block_on( + BatchTransition::new_document_replacement_transition_from_document( + document, + document_type, + &identity_key, + identity_contract_nonce, + 0, + None, + &signer, + version, + None, + ), + ) + .map_err(|e| format!("unable to build profile replace transition: {e}"))?; + built(&state_transition, version) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_contact_request( + 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 version = platform_version()?; + 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_create_transition( + &contract, + "contactRequest", + document, + identity_contract_nonce, + 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( + 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, platform_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..8e0d8629ec5 --- /dev/null +++ b/packages/rs-platform-cxx/src/types.rs @@ -0,0 +1,113 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! 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)] +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)] +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)] +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)] +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 proved DAPI response. The +/// Tenderdash quorum signature covers all of them (they enter the StateId / +/// CanonicalVote sign bytes), so after `FromProof` verification succeeds the +/// embedder's freshness policy can trust them. Verification itself imposes no +/// freshness floor: which heights are recent enough is the embedder's call. +#[derive(Debug, Clone, Default)] +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], +} + +pub fn id32(bytes: &[u8], what: &str) -> Result<[u8; 32], String> { + bytes + .try_into() + .map_err(|_| format!("{what} must be 32 bytes, got {}", bytes.len())) +} diff --git a/packages/rs-platform-cxx/src/verify.rs b/packages/rs-platform-cxx/src/verify.rs new file mode 100644 index 00000000000..85d91fa7b78 --- /dev/null +++ b/packages/rs-platform-cxx/src/verify.rs @@ -0,0 +1,257 @@ +// 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. + +//! Drive proved-response verification for the DAPI queries exposed by this +//! binding, built on drive-proof-verifier's `FromProof`. Each function takes +//! the exact protobuf request the transport sent plus the full protobuf +//! response it received, reconstructs the query from the request, replays the +//! GroveDB proof, and verifies the Tenderdash BLS quorum threshold signature +//! against the keys served by [`crate::provider`]. +//! +//! Every byte that enters here comes from an untrusted node, and the GroveDB +//! replay necessarily runs before the signature check (the root hash only +//! exists after replay). Inputs are therefore size-capped at the door, and +//! the caller's freshness policy runs on the returned [`Meta`] — the +//! signature binds those fields, nothing here decides whether they are recent +//! enough. + +use dapi_grpc::platform::v0::{ + GetContestedResourceVoteStateRequest, GetContestedResourceVoteStateResponse, + GetDocumentsRequest, GetDocumentsResponse, GetIdentityByPublicKeyHashRequest, + GetIdentityByPublicKeyHashResponse, GetIdentityContractNonceRequest, + GetIdentityContractNonceResponse, GetIdentityNonceRequest, GetIdentityNonceResponse, + GetIdentityRequest, GetIdentityResponse, ResponseMetadata, +}; +use dapi_grpc::platform::VersionedGrpcResponse; +use dash_context_provider::ContextProvider as _; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::identity::Identity; +use dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo as WinnerInfo; +use drive_proof_verifier::from_request::TryFromRequest; +use drive_proof_verifier::types::{ + Contenders, Documents, IdentityContractNonceFetcher, IdentityNonceFetcher, +}; +use drive_proof_verifier::{DocumentWireQuery, FromProof, RequestedDocuments}; +use platform_version::version::PlatformVersion; +use prost::Message; + +use crate::decode::identity_info; +use crate::provider::{context, provider}; +use crate::types::{ContestedVoteState, IdentityInfo, Meta}; + +/// Largest request or response the bridge will decode. DAPI's gRPC servers +/// cap messages well below this; a larger blob is not a Platform response +/// and would only be an attempt to make the decoders allocate. +pub const MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024; + +fn decode_message(bytes: &[u8], what: &str) -> Result { + if bytes.len() > MAX_MESSAGE_BYTES { + return Err(format!( + "{what} is {} bytes, above the {MAX_MESSAGE_BYTES}-byte limit", + bytes.len() + )); + } + T::decode(bytes).map_err(|e| format!("unable to decode {what}: {e}")) +} + +fn meta_from(mtd: &ResponseMetadata) -> Meta { + Meta { + height: mtd.height, + core_chain_locked_height: mtd.core_chain_locked_height, + time_ms: mtd.time_ms, + protocol_version: mtd.protocol_version, + chain_id: mtd.chain_id.clone(), + } +} + +/// The platform version the response claims to be produced under. The claim +/// is authenticated after the fact: `protocol_version` enters the signed +/// StateId, so a lie fails the quorum signature check. A version this build +/// does not know cannot be verified at all. +fn response_version( + response: &R, +) -> Result<&'static PlatformVersion, String> +where + ::Error: std::fmt::Display, +{ + let mtd = response + .metadata() + .map_err(|e| format!("response has no metadata: {e}"))?; + PlatformVersion::get(mtd.protocol_version).map_err(|e| { + format!( + "response claims protocol version {} this build does not know: {e}", + mtd.protocol_version + ) + }) +} + +macro_rules! verify_with { + ($request:expr, $response:expr, $req_ty:ty, $resp_ty:ty, $out_ty:ty, $what:literal) => {{ + let request: $req_ty = decode_message($request, concat!($what, " request"))?; + let response: $resp_ty = decode_message($response, concat!($what, " response"))?; + let version = response_version(&response)?; + let (value, mtd, _) = <$out_ty as FromProof<$req_ty>>::maybe_from_proof_with_metadata( + request, + response, + context()?.network, + version, + provider(), + ) + .map_err(|e| format!(concat!($what, " proof verification failed: {}"), e))?; + (value, meta_from(&mtd), version) + }}; +} + +pub fn verify_get_identity_nonce( + request: &[u8], + response: &[u8], +) -> Result<(Option, Meta), String> { + let (nonce, meta, _) = verify_with!( + request, + response, + GetIdentityNonceRequest, + GetIdentityNonceResponse, + IdentityNonceFetcher, + "identity nonce" + ); + Ok((nonce.map(|fetcher| fetcher.0), meta)) +} + +pub fn verify_get_identity_contract_nonce( + request: &[u8], + response: &[u8], +) -> Result<(Option, Meta), String> { + let (nonce, meta, _) = verify_with!( + request, + response, + GetIdentityContractNonceRequest, + GetIdentityContractNonceResponse, + IdentityContractNonceFetcher, + "identity contract nonce" + ); + Ok((nonce.map(|fetcher| fetcher.0), meta)) +} + +/// getIdentity: one proof covering balance, revision and keys. `None` = +/// proven absent. +pub fn verify_get_identity( + request: &[u8], + response: &[u8], +) -> Result<(Option, Meta), String> { + let (identity, meta, _) = verify_with!( + request, + response, + GetIdentityRequest, + GetIdentityResponse, + Identity, + "identity" + ); + Ok((identity.as_ref().map(identity_info), meta)) +} + +/// getIdentityByPublicKeyHash: one proof resolving the unique key hash to +/// the full identity. `None` = no identity registered that key hash. +pub fn verify_get_identity_by_pubkey_hash( + request: &[u8], + response: &[u8], +) -> Result<(Option, Meta), String> { + let (identity, meta, _) = verify_with!( + request, + response, + GetIdentityByPublicKeyHashRequest, + GetIdentityByPublicKeyHashResponse, + Identity, + "identity-by-public-key-hash" + ); + Ok((identity.as_ref().map(identity_info), meta)) +} + +/// getDocuments: the query is reconstructed from the wire request by +/// drive-proof-verifier (`FromProof`), verified, and +/// the matched documents are returned re-serialized in platform form (the +/// input to the decode_* functions). No matching documents is an empty +/// vector. +pub fn verify_get_documents( + request: &[u8], + response: &[u8], +) -> Result<(Vec>, Meta), String> { + let wire_request: GetDocumentsRequest = decode_message(request, "documents request")?; + // The verifier binds the proof to the request's contract and document + // type; re-serializing the verified documents needs the same pair. + let wire_query = DocumentWireQuery::try_from_request(wire_request.clone()) + .map_err(|e| format!("documents request: {e}"))?; + + let (documents, meta, version) = verify_with!( + request, + response, + GetDocumentsRequest, + GetDocumentsResponse, + RequestedDocuments, + "documents" + ); + let Some(documents) = documents else { + return Ok((Vec::new(), meta)); + }; + let contract = provider() + .get_data_contract(&wire_query.data_contract_id, version) + .map_err(|e| format!("unable to resolve data contract: {e}"))? + .ok_or("document queries support only the pinned system contracts")?; + let document_type = contract + .document_type_for_name(&wire_query.document_type_name) + .map_err(|e| { + format!( + "unknown document type {}: {e}", + wire_query.document_type_name + ) + })?; + let serialized = Documents::from(documents) + .into_iter() + .filter_map(|(_, document)| document) + .map(|document| { + document + .serialize(document_type, &contract, version) + .map_err(|e| format!("unable to re-serialize verified document: {e}")) + }) + .collect::, _>>()?; + Ok((serialized, meta)) +} + +/// getContestedResourceVoteState (VoteTally result type). The query shape — +/// contract, document type, index values, tally options, count — is +/// reconstructed from the request itself. +pub fn verify_get_contested_vote_state( + request: &[u8], + response: &[u8], +) -> Result<(ContestedVoteState, Meta), String> { + let (contenders, meta, _) = verify_with!( + request, + response, + GetContestedResourceVoteStateRequest, + GetContestedResourceVoteStateResponse, + Contenders, + "contested vote state" + ); + let mut state = ContestedVoteState::default(); + if let Some(contenders) = contenders { + 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 => {} + } + } + } + Ok((state, meta)) +} 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..3155f33dbbd --- /dev/null +++ b/packages/rs-platform-cxx/tests/common/mod.rs @@ -0,0 +1,156 @@ +// 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. + +//! Fixture access shared by the bridge's integration tests. +//! +//! Proofs, quorum signature and quorum key come from drive-proof-verifier's +//! proof-vector corpus (`../rs-drive-proof-verifier/tests/vectors`), so the +//! bridge verifies exactly the bytes the upstream verifier is pinned against. +//! Every fixture proof commits to the same root hash and the fixture quorum +//! signed that root, so positive cases run grovedb replay plus the Tenderdash +//! BLS check end to end. + +#![allow(dead_code)] + +use std::path::PathBuf; +use std::sync::Once; + +use dapi_grpc::platform::v0::{Proof, ResponseMetadata}; +use serde::Deserialize; + +/// The Platform LLMQ type the corpus quorum belongs to. +pub const QUORUM_TYPE: u32 = 106; + +#[derive(Deserialize)] +pub struct Manifest { + pub request: serde_json::Value, + pub expected: serde_json::Value, + pub block: BlockMeta, + pub proof_meta: ProofMeta, + #[serde(default)] + pub expected_root_hash_hex: Option, +} + +#[derive(Deserialize)] +pub struct BlockMeta { + pub height: u64, + pub core_chain_locked_height: u32, + pub epoch: u16, + pub time_ms: u64, + pub protocol_version: u32, + pub chain_id: String, +} + +#[derive(Deserialize)] +pub struct ProofMeta { + pub round: u32, + pub quorum_type: u32, + pub quorum_hash_hex: String, + pub block_id_hash_hex: String, +} + +pub struct Case { + pub name: String, + pub manifest: Manifest, + pub grovedb_proof: Vec, + pub signature: Vec, + pub quorum_pubkey: [u8; 48], +} + +pub fn hex_vec(s: &str) -> Vec { + hex::decode(s).expect("corpus hex") +} + +pub fn hex32(s: &str) -> [u8; 32] { + hex_vec(s).try_into().expect("32 bytes") +} + +pub 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}")) + }; + let manifest: Manifest = + serde_json::from_str(&read("manifest.json")).expect("parse corpus manifest"); + Case { + name: name.to_string(), + 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"), + manifest, + } +} + +impl Case { + pub 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, + } + } + + pub 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(), + } + } + + pub fn identity_id(&self) -> Vec { + hex_vec( + self.manifest.request["identity_id"] + .as_str() + .expect("identity_id"), + ) + } + + pub fn check_meta(&self, meta: &dash_platform_cxx::types::Meta) { + assert_eq!(meta.height, self.manifest.block.height); + assert_eq!( + meta.core_chain_locked_height, + self.manifest.block.core_chain_locked_height + ); + assert_eq!(meta.time_ms, self.manifest.block.time_ms); + assert_eq!(meta.protocol_version, self.manifest.block.protocol_version); + assert_eq!(meta.chain_id, self.manifest.block.chain_id); + } +} + +/// Installs the fixture context exactly once per test binary: the corpus +/// network, quorum type, protocol version and the corpus quorum key. Tests +/// that need a failing key lookup tamper the response instead of mutating +/// this shared store. +pub fn setup() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let case = load_case("quorum-sig-valid"); + dash_platform_cxx::provider::set_context( + "test", + case.manifest.proof_meta.quorum_type, + case.manifest.block.protocol_version, + 0, + ) + .expect("set_context"); + dash_platform_cxx::provider::update_quorum_keys(vec![ + dash_platform_cxx::provider::QuorumKey { + quorum_hash: hex32(&case.manifest.proof_meta.quorum_hash_hex), + public_key: case.quorum_pubkey, + }, + ]) + .expect("update_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..ec89e41e4ee --- /dev/null +++ b/packages/rs-platform-cxx/tests/cxx_smoke.cc @@ -0,0 +1,111 @@ +// 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: context setup, a fallible verify (expected to throw rust::Error on +// garbage input rather than abort), and a builder driven through a +// WalletSigner callback (refused because the signer declines). + +#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() +{ + try { + platform_ffi::set_context("test", std::uint32_t{106}, std::uint32_t{12}, std::uint32_t{0}); + } catch (const rust::Error& e) { + return fail(e.what()); + } + + // Garbage bytes must surface as an exception, never an abort. + bool threw = false; + try { + const std::array garbage{0xff, 0xff, 0xff, 0xff}; + platform_ffi::verify_get_identity_nonce( + rust::Slice(garbage.data(), garbage.size()), + rust::Slice(garbage.data(), garbage.size())); + } catch (const rust::Error&) { + threw = true; + } + if (!threw) return fail("garbage input verified"); + + // 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; + const std::array id{}; + threw = false; + try { + platform_ffi::st_build_dpns_preorder( + rust::Slice(id.data(), id.size()), std::uint64_t{1}, "alice", + rust::Slice(id.data(), id.size()), std::uint32_t{1}, key, signer); + } catch (const rust::Error&) { + threw = true; + } + if (!threw) return fail("declined signer produced a transition"); + + // 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); + }; + const rust::Slice id_slice(id.data(), id.size()); + if (expect_error("st_build_dpns_domain", [&] { + platform_ffi::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", [&] { + platform_ffi::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); + platform_ffi::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; + platform_ffi::st_build_identity_create(false, id_slice, id_slice, 0, 0, + rust::Slice(id.data(), 32), + std::move(keys), signer); + })) + return 1; + 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..daeb8e8deb0 --- /dev/null +++ b/packages/rs-platform-cxx/tests/decoders.rs @@ -0,0 +1,191 @@ +// 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 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; +/// install that as the network the builders and decoders work against. +fn setup() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + dash_platform_cxx::provider::set_context("test", 106, 12, 0).expect("set_context"); + }); +} + +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() { + setup(); + 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() { + setup(); + assert!(decode::decode_identity(&[0xff; 16]).is_err()); +} + +#[test] +fn decode_stored_dpns_domain() { + setup(); + let doc = st_vectors(); + let vector = &doc["stored_documents"]["domain"]; + let name = decode::decode_dpns_domain(&hexv(vector["serialized_hex"].as_str().unwrap())) + .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() { + setup(); + let doc = st_vectors(); + let vector = &doc["stored_documents"]["profile"]; + let profile = decode::decode_dashpay_profile(&hexv(vector["serialized_hex"].as_str().unwrap())) + .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() { + setup(); + let doc = st_vectors(); + let vector = &doc["stored_documents"]["contact"]; + let request = decode::decode_contact_request(&hexv(vector["serialized_hex"].as_str().unwrap())) + .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() { + setup(); + assert!(decode::decode_dpns_domain(b"proved-dpns-document").is_err()); + assert!(decode::decode_dashpay_profile(b"proved-profile-document").is_err()); + assert!(decode::decode_contact_request(b"proved-contact-document").is_err()); +} + +/// Decoder inputs are bounded like every other byte crossing the bridge. +#[test] +fn oversized_decoder_inputs_are_refused() { + setup(); + let huge = vec![0u8; dash_platform_cxx::verify::MAX_MESSAGE_BYTES + 1]; + for result in [ + decode::decode_identity(&huge).map(|_| ()), + decode::decode_identity_public_key(&huge).map(|_| ()), + decode::decode_dpns_domain(&huge).map(|_| ()), + ] { + let err = result.expect_err("oversized input"); + assert!(err.contains("above the"), "{err}"); + } +} diff --git a/packages/rs-platform-cxx/tests/from_proof.rs b/packages/rs-platform-cxx/tests/from_proof.rs new file mode 100644 index 00000000000..f3a4cda5680 --- /dev/null +++ b/packages/rs-platform-cxx/tests/from_proof.rs @@ -0,0 +1,444 @@ +// 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. + +//! Acceptance tests for the FromProof-driven (request bytes, response bytes) +//! verification seam: synthesize the DAPI protobuf request/response pairs the +//! C++ transport exchanges around the proof-vector corpus, push the corpus +//! quorum key through the provider store, and verify end to end (grovedb +//! replay + Tenderdash BLS quorum signature). +//! +//! The corpus stores placeholder payloads at document positions, so document +//! queries pin a clean decode failure (as the upstream corpus does) while the +//! identity and contested-vote families verify positively. + +mod common; + +use common::{hex_vec, load_case, setup, Case, QUORUM_TYPE}; +use dapi_grpc::platform::v0::{self as proto, Proof, ResponseMetadata}; +use dash_platform_cxx::verify; +use prost::Message; + +const DPNS_CONTRACT_ID_HEX: &str = + "e668c659af66aee1e72c186dde7b5b7e0a1d712a09c40d5721f622bf53c53155"; + +// --- request/response synthesis used by transport adapters ------------------ + +fn identity_nonce_request(identity_id: Vec) -> Vec { + proto::GetIdentityNonceRequest { + version: Some(proto::get_identity_nonce_request::Version::V0( + proto::get_identity_nonce_request::GetIdentityNonceRequestV0 { + identity_id, + prove: true, + }, + )), + } + .encode_to_vec() +} + +fn identity_nonce_response(proof: Proof, metadata: ResponseMetadata) -> Vec { + proto::GetIdentityNonceResponse { + version: Some(proto::get_identity_nonce_response::Version::V0( + proto::get_identity_nonce_response::GetIdentityNonceResponseV0 { + metadata: Some(metadata), + result: Some( + proto::get_identity_nonce_response::get_identity_nonce_response_v0::Result::Proof(proof), + ), + }, + )), + } + .encode_to_vec() +} + +fn identity_contract_nonce_request(identity_id: Vec, contract_id: Vec) -> Vec { + proto::GetIdentityContractNonceRequest { + version: Some(proto::get_identity_contract_nonce_request::Version::V0( + proto::get_identity_contract_nonce_request::GetIdentityContractNonceRequestV0 { + identity_id, + contract_id, + prove: true, + }, + )), + } + .encode_to_vec() +} + +fn identity_contract_nonce_response(proof: Proof, metadata: ResponseMetadata) -> Vec { + 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), + ), + }, + )), + } + .encode_to_vec() +} + +fn identity_request(id: Vec) -> Vec { + proto::GetIdentityRequest { + version: Some(proto::get_identity_request::Version::V0( + proto::get_identity_request::GetIdentityRequestV0 { id, prove: true }, + )), + } + .encode_to_vec() +} + +fn identity_response(proof: Proof, metadata: ResponseMetadata) -> Vec { + proto::GetIdentityResponse { + version: Some(proto::get_identity_response::Version::V0( + proto::get_identity_response::GetIdentityResponseV0 { + metadata: Some(metadata), + result: Some( + proto::get_identity_response::get_identity_response_v0::Result::Proof(proof), + ), + }, + )), + } + .encode_to_vec() +} + +/// bincode (standard config) encoding of a platform Value::Text, as +/// drive-abci decodes contested index values (discriminant 18 + length + +/// utf8; labels are short so the length is a single byte). +fn bincode_text(text: &str) -> Vec { + let mut out = vec![18u8, u8::try_from(text.len()).expect("short label")]; + out.extend_from_slice(text.as_bytes()); + out +} + +fn contested_request(case: &Case) -> Vec { + let request = &case.manifest.request; + let index_values: Vec> = request["index_values"] + .as_array() + .expect("index_values") + .iter() + .map(|value| bincode_text(value.as_str().expect("text index value"))) + .collect(); + proto::GetContestedResourceVoteStateRequest { + version: Some(proto::get_contested_resource_vote_state_request::Version::V0( + proto::get_contested_resource_vote_state_request::GetContestedResourceVoteStateRequestV0 { + contract_id: hex_vec(request["contract_id"].as_str().expect("contract_id")), + document_type_name: request["document_type_name"].as_str().expect("type").to_string(), + index_name: request["index_name"].as_str().expect("index").to_string(), + index_values, + result_type: + proto::get_contested_resource_vote_state_request::get_contested_resource_vote_state_request_v0::ResultType::VoteTally + .into(), + allow_include_locked_and_abstaining_vote_tally: true, + start_at_identifier_info: None, + count: Some(request["count"].as_u64().expect("count") as u32), + prove: true, + }, + )), + } + .encode_to_vec() +} + +fn contested_response(proof: Proof, metadata: ResponseMetadata) -> Vec { + proto::GetContestedResourceVoteStateResponse { + version: Some(proto::get_contested_resource_vote_state_response::Version::V0( + proto::get_contested_resource_vote_state_response::GetContestedResourceVoteStateResponseV0 { + metadata: Some(metadata), + result: Some( + proto::get_contested_resource_vote_state_response::get_contested_resource_vote_state_response_v0::Result::Proof(proof), + ), + }, + )), + } + .encode_to_vec() +} + +/// CBOR where clauses exactly as the C++ transport encodes them +/// (transport/cbor.h): definite-length arrays of [field, operator, value]. +fn dpns_exact_documents_request(contract_id: Vec, normalized_label: &str) -> Vec { + let clauses = vec![ + vec![ + ciborium::Value::Text("normalizedParentDomainName".to_string()), + ciborium::Value::Text("==".to_string()), + ciborium::Value::Text("dash".to_string()), + ], + vec![ + ciborium::Value::Text("normalizedLabel".to_string()), + ciborium::Value::Text("==".to_string()), + ciborium::Value::Text(normalized_label.to_string()), + ], + ]; + let cbor = ciborium::Value::Array( + clauses + .into_iter() + .map(ciborium::Value::Array) + .collect::>(), + ); + let mut where_bytes = Vec::new(); + ciborium::into_writer(&cbor, &mut where_bytes).expect("encode where clauses"); + proto::GetDocumentsRequest { + version: Some(proto::get_documents_request::Version::V0( + proto::get_documents_request::GetDocumentsRequestV0 { + data_contract_id: contract_id, + document_type: "domain".to_string(), + r#where: where_bytes, + order_by: Vec::new(), + limit: 1, + prove: true, + start: None, + }, + )), + } + .encode_to_vec() +} + +fn documents_response(proof: Proof, metadata: ResponseMetadata) -> Vec { + proto::GetDocumentsResponse { + version: Some(proto::get_documents_response::Version::V0( + proto::get_documents_response::GetDocumentsResponseV0 { + metadata: Some(metadata), + result: Some( + proto::get_documents_response::get_documents_response_v0::Result::Proof(proof), + ), + }, + )), + } + .encode_to_vec() +} + +// --- positive cases --------------------------------------------------------- + +#[test] +fn identity_nonce_verifies_end_to_end() { + setup(); + let case = load_case("identity-nonce"); + let request = identity_nonce_request(case.identity_id()); + let response = identity_nonce_response(case.proof(), case.metadata()); + let (nonce, meta) = + verify::verify_get_identity_nonce(&request, &response).expect("identity nonce verifies"); + assert_eq!(nonce, case.manifest.expected["nonce"].as_u64()); + case.check_meta(&meta); +} + +#[test] +fn identity_contract_nonce_verifies_end_to_end() { + setup(); + let case = load_case("identity-contract-nonce"); + let contract_id = hex_vec( + case.manifest.request["contract_id"] + .as_str() + .expect("contract"), + ); + let request = identity_contract_nonce_request(case.identity_id(), contract_id); + let response = identity_contract_nonce_response(case.proof(), case.metadata()); + let (nonce, meta) = verify::verify_get_identity_contract_nonce(&request, &response) + .expect("identity contract nonce verifies"); + assert_eq!(nonce, case.manifest.expected["nonce"].as_u64()); + case.check_meta(&meta); +} + +#[test] +fn contested_vote_state_active_verifies_end_to_end() { + setup(); + let case = load_case("contested-vote-state-active"); + let (state, meta) = verify::verify_get_contested_vote_state( + &contested_request(&case), + &contested_response(case.proof(), case.metadata()), + ) + .expect("contested vote state verifies"); + case.check_meta(&meta); + assert!(state.contest_found); + assert!(!state.finished); + let expected = case.manifest.expected["contenders"].as_array().unwrap(); + assert_eq!(state.contenders.len(), expected.len()); + for ((identity, votes), expected) in state.contenders.iter().zip(expected) { + assert_eq!( + hex::encode(identity), + expected["identity_id"].as_str().unwrap() + ); + assert_eq!(votes.map(u64::from), expected["votes"].as_u64()); + } + assert_eq!( + state.abstain_votes.map(u64::from), + case.manifest.expected["abstain_votes"].as_u64() + ); + assert_eq!( + state.lock_votes.map(u64::from), + case.manifest.expected["lock_votes"].as_u64() + ); +} + +#[test] +fn contested_vote_state_finished_verifies_end_to_end() { + setup(); + let case = load_case("contested-vote-state-finished"); + let (state, _) = verify::verify_get_contested_vote_state( + &contested_request(&case), + &contested_response(case.proof(), case.metadata()), + ) + .expect("contested vote state verifies"); + assert!(state.contest_found); + assert!(state.finished); + assert!(!state.locked); + assert_eq!( + state.winner.map(hex::encode), + case.manifest.expected["winner_identity_id"] + .as_str() + .map(String::from) + ); + assert!(state.finished_at_time_ms > 0); +} + +#[test] +fn contested_vote_state_absent_is_proven() { + setup(); + let case = load_case("contested-vote-state-absent"); + let (state, _) = verify::verify_get_contested_vote_state( + &contested_request(&case), + &contested_response(case.proof(), case.metadata()), + ) + .expect("contested vote state verifies"); + assert!(!state.contest_found); + assert!(state.contenders.is_empty()); +} + +/// The corpus grovedb state stores placeholder payloads at document +/// positions: the grovedb + query-shape verification succeeds, and the +/// document decode must fail cleanly (an Err, never an abort). +#[test] +fn placeholder_documents_fail_decoding_cleanly() { + setup(); + let case = load_case("dpns-domain-exact"); + let request = dpns_exact_documents_request(hex_vec(DPNS_CONTRACT_ID_HEX), "alice"); + let response = documents_response(case.proof(), case.metadata()); + let err = verify::verify_get_documents(&request, &response) + .expect_err("placeholder documents must not decode"); + // A query-shape drift would surface as a grovedb proof error instead. + assert!( + !err.contains("grovedb"), + "unexpected proof-layer error: {err}" + ); +} + +// --- negative cases: quorum binding ----------------------------------------- + +fn nonce_case_with(mutate: impl FnOnce(&mut Proof, &mut ResponseMetadata)) -> Result<(), String> { + setup(); + let case = load_case("identity-nonce"); + let mut proof = case.proof(); + let mut metadata = case.metadata(); + mutate(&mut proof, &mut metadata); + verify::verify_get_identity_nonce( + &identity_nonce_request(case.identity_id()), + &identity_nonce_response(proof, metadata), + ) + .map(|_| ()) +} + +#[test] +fn tampered_signature_is_rejected() { + nonce_case_with(|proof, _| proof.signature[10] ^= 0x01).expect_err("tampered signature"); +} + +#[test] +fn unknown_quorum_hash_is_rejected() { + let err = nonce_case_with(|proof, _| proof.quorum_hash[0] ^= 0x01).expect_err("unknown quorum"); + assert!(err.contains("quorum"), "unexpected error: {err}"); +} + +/// A proof naming a quorum type other than the network's Platform type is +/// refused before any key is looked up, whatever keys the embedder pushed. +#[test] +fn non_platform_quorum_type_is_rejected() { + let err = nonce_case_with(|proof, _| proof.quorum_type = QUORUM_TYPE + 1) + .expect_err("non-platform quorum type"); + assert!( + err.contains("quorum type"), + "expected the quorum-type gate, got: {err}" + ); +} + +#[test] +fn tampered_block_id_hash_is_rejected() { + nonce_case_with(|proof, _| proof.block_id_hash[0] ^= 0x01).expect_err("tampered block id"); +} + +#[test] +fn tampered_grovedb_proof_is_rejected() { + nonce_case_with(|proof, _| { + let mid = proof.grovedb_proof.len() / 2; + proof.grovedb_proof[mid] ^= 0x01; + }) + .expect_err("tampered grovedb proof"); +} + +/// Every signed metadata field is part of the quorum-signature preimage: a +/// replayed proof with any one altered must fail even though the signature +/// is valid for the original block. +#[test] +fn tampered_signed_metadata_is_rejected() { + nonce_case_with(|_, mtd| mtd.height += 1).expect_err("height"); + nonce_case_with(|_, mtd| mtd.time_ms += 1).expect_err("time_ms"); + nonce_case_with(|_, mtd| mtd.core_chain_locked_height += 1).expect_err("core height"); + nonce_case_with(|_, mtd| mtd.chain_id.push('x')).expect_err("chain id"); + nonce_case_with(|proof, _| proof.round += 1).expect_err("round"); +} + +/// A response claiming a protocol version this build does not know cannot be +/// verified; it must be refused, not verified under a guessed version. +#[test] +fn unknown_protocol_version_is_rejected() { + let err = nonce_case_with(|_, mtd| mtd.protocol_version = u32::MAX) + .expect_err("unknown protocol version"); + assert!(err.contains("protocol version"), "unexpected error: {err}"); +} + +// --- negative cases: request binding ---------------------------------------- + +/// A proof for identity A presented with a request for identity B must fail. +#[test] +fn proof_for_a_different_identity_is_rejected() { + setup(); + let case = load_case("identity-nonce"); + let mut other = case.identity_id(); + other[0] ^= 0x01; + verify::verify_get_identity_nonce( + &identity_nonce_request(other), + &identity_nonce_response(case.proof(), case.metadata()), + ) + .expect_err("request/proof identity mismatch"); +} + +/// A structurally valid proof for a different query shape must fail cleanly +/// when presented as a full-identity proof. +#[test] +fn identity_rejects_wrong_shape_proof() { + setup(); + let case = load_case("identity-nonce"); + let err = verify::verify_get_identity( + &identity_request(case.identity_id()), + &identity_response(case.proof(), case.metadata()), + ) + .expect_err("nonce-shaped proof must not satisfy full-identity verification"); + assert!(err.contains("identity proof verification failed"), "{err}"); +} + +// --- input hygiene ---------------------------------------------------------- + +#[test] +fn oversized_messages_are_refused_before_decoding() { + setup(); + let huge = vec![0u8; verify::MAX_MESSAGE_BYTES + 1]; + let err = verify::verify_get_identity_nonce(&huge, &[]).expect_err("oversized request"); + assert!(err.contains("above the"), "{err}"); + let err = verify::verify_get_identity_nonce(&[], &huge).expect_err("oversized response"); + assert!(err.contains("above the"), "{err}"); +} + +#[test] +fn garbage_and_empty_inputs_fail_cleanly() { + setup(); + for bytes in [&b""[..], &[0xffu8; 64][..], b"not protobuf at all"] { + verify::verify_get_identity_nonce(bytes, bytes).expect_err("garbage input"); + verify::verify_get_documents(bytes, bytes).expect_err("garbage input"); + verify::verify_get_contested_vote_state(bytes, bytes).expect_err("garbage input"); + } +} diff --git a/packages/rs-platform-cxx/tests/signing.rs b/packages/rs-platform-cxx/tests/signing.rs new file mode 100644 index 00000000000..e6bbb8b3b37 --- /dev/null +++ b/packages/rs-platform-cxx/tests/signing.rs @@ -0,0 +1,494 @@ +// 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 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; +/// install that as the network the builders and decoders work against. +fn setup() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + dash_platform_cxx::provider::set_context("test", 106, 12, 0).expect("set_context"); + }); +} + +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() { + setup(); + let doc = vectors(); + let vector = &doc["dpns_preorder"]; + let signer = TestSigner::from_vectors(&doc); + let built = st::build_dpns_preorder( + &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( + &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() { + setup(); + 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() { + setup(); + 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() { + setup(); + let doc = vectors(); + let signer = TestSigner::from_vectors(&doc); + let err = st::build_dpns_domain( + &[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() { + setup(); + let doc = vectors(); + let vector = &doc["dashpay_profile_create"]; + let signer = TestSigner::from_vectors(&doc); + let built = st::build_profile( + &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() { + setup(); + 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( + &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() { + setup(); + let doc = vectors(); + let vector = &doc["dashpay_contact_request"]; + let signer = TestSigner::from_vectors(&doc); + let built = st::build_contact_request( + &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() { + setup(); + let doc = vectors(); + let vector = &doc["identity_create_instant"]; + let signer = TestSigner::from_vectors(&doc); + let built = st::build_identity_create( + 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() { + setup(); + let doc = vectors(); + let vector = &doc["identity_create_chain"]; + let signer = TestSigner::from_vectors(&doc); + let built = st::build_identity_create( + 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() { + setup(); + 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(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(proof(), &duplicated, &|key_id, digest| { + signer.sign(key_id, digest) + }) + .unwrap_err(); + assert!(err.contains("duplicate"), "{err}"); +} + +#[test] +fn signer_failure_is_reported() { + setup(); + let doc = vectors(); + let vector = &doc["dpns_preorder"]; + let err = st::build_dpns_preorder( + &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() { + setup(); + 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( + &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() { + setup(); + let doc = vectors(); + let vector = &doc["dpns_preorder"]; + let err = st::build_dpns_preorder( + &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() { + setup(); + let doc = vectors(); + let signer = TestSigner::from_vectors(&doc); + let err = st::build_dpns_domain( + &[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" + ); +}