From 5bb4e3a932dc6d6979ffff58b934edef93586be8 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 01:09:03 -0500 Subject: [PATCH 1/5] feat(sdk): add dash-sdk-cxx, a C++ binding of dash-sdk for Dash Core A cxx bridge (C++ namespace dash_sdk, headers dash/sdk/ffi.h and dash/sdk/callbacks.h) for an embedder that is its own source of truth. Each entry point forwards to dash-sdk and returns a typed status: Ok and NotFound only come from proofs. - Trust: dash-sdk's pull ContextProvider over a C++ CoreContext. Proofs must name the configured Platform LLMQ type (checked before narrowing), the embedder answers by type and hash, and anything it does not hold fails closed. DPNS and DashPay contracts come from dpp. - Transport: endpoints from the embedder only (https, literal IP, onion with a proxy); SOCKS5 through SdkBuilder::with_proxy. - Writes: the SDK's end-to-end identity create, top-up, DPNS registration (with its contest_fund) and contact request, signed through a C++ WalletSigner and ContactKeys. No private key crosses the FFI. - Reads: identity by id and key hash, name resolution, names of an identity (caller-bounded paging), prefix search, an identity's pending contests and a contest's vote state, and the contact request sweep. - Runtime: calls run on the binding's tokio runtime; shutdown cancels and joins every call and destroys the CoreContext before any shutdown caller returns; callbacks run under block_in_place; every exported body runs under a panic guard and C++ exceptions stay in the callbacks. Tests are binding-level: a C++ smoke test that crosses every entry point and every callback method, plus panic and exception containment, concurrent shutdown, marshalling, the status table and the CoreContext checks. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 116 +++ Cargo.toml | 1 + packages/rs-sdk-cxx/Cargo.toml | 27 + packages/rs-sdk-cxx/build.rs | 37 + .../rs-sdk-cxx/include/dash/sdk/callbacks.h | 180 +++++ packages/rs-sdk-cxx/scripts/cxx-smoke.sh | 32 + packages/rs-sdk-cxx/src/client.rs | 719 ++++++++++++++++++ packages/rs-sdk-cxx/src/context.rs | 214 ++++++ packages/rs-sdk-cxx/src/lib.rs | 523 +++++++++++++ packages/rs-sdk-cxx/src/marshal.rs | 232 ++++++ packages/rs-sdk-cxx/src/runtime.rs | 241 ++++++ packages/rs-sdk-cxx/src/signer.rs | 188 +++++ packages/rs-sdk-cxx/tests/cxx_smoke.cc | 312 ++++++++ 13 files changed, 2822 insertions(+) create mode 100644 packages/rs-sdk-cxx/Cargo.toml create mode 100644 packages/rs-sdk-cxx/build.rs create mode 100644 packages/rs-sdk-cxx/include/dash/sdk/callbacks.h create mode 100755 packages/rs-sdk-cxx/scripts/cxx-smoke.sh create mode 100644 packages/rs-sdk-cxx/src/client.rs create mode 100644 packages/rs-sdk-cxx/src/context.rs create mode 100644 packages/rs-sdk-cxx/src/lib.rs create mode 100644 packages/rs-sdk-cxx/src/marshal.rs create mode 100644 packages/rs-sdk-cxx/src/runtime.rs create mode 100644 packages/rs-sdk-cxx/src/signer.rs create mode 100644 packages/rs-sdk-cxx/tests/cxx_smoke.cc diff --git a/Cargo.lock b/Cargo.lock index 90ca9978ddb..d1c15369525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1261,6 +1261,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" @@ -1589,6 +1600,68 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "cxx" +version = "1.0.205" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8a02bfeba9074b2bbb39492c9b0908ec854f455a3cccdcde7a1de55e2315e40" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash 0.2.0", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.205" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc2ac76997619d2d888b58ad79488f1c835de41ca4dd3dd7082acf4ecb0a50f" +dependencies = [ + "cc", + "codespan-reporting", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "scratch", + "syn 3.0.5", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.205" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a486ed16a3f2a053fe7f5b98db4c1a59846a23e8cb9e49f9d1bd48830d51a2" +dependencies = [ + "clap", + "codespan-reporting", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.205" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c72569be94e2283764d334cd285980b747dbe221e27e315484eb6bcd93fa808a" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.205" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "423e795284a7f7b3f843c956a5322c4bc497b60c2dd1a6339da10313cb18d575" +dependencies = [ + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "dapi-grpc" version = "5.0.1" @@ -1862,6 +1935,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dash-sdk-cxx" +version = "5.0.1" +dependencies = [ + "async-trait", + "cxx", + "cxx-build", + "dash-sdk", + "platform-encryption", + "tokio", + "zeroize", +] + [[package]] name = "dash-spv" version = "0.46.0" @@ -4441,6 +4527,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" @@ -6860,6 +6955,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 = "sdd" version = "3.0.10" @@ -7614,6 +7715,15 @@ dependencies = [ "zip", ] +[[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" @@ -8436,6 +8546,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 d52acd39390..c58eb50482b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ members = [ "packages/app-connect-contract", "packages/moderation-charters-contract", "packages/rs-sdk-ffi", + "packages/rs-sdk-cxx", "packages/wasm-drive-verify", "packages/dash-platform-balance-checker", "packages/rs-dapi", diff --git a/packages/rs-sdk-cxx/Cargo.toml b/packages/rs-sdk-cxx/Cargo.toml new file mode 100644 index 00000000000..10ae77cdf49 --- /dev/null +++ b/packages/rs-sdk-cxx/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "dash-sdk-cxx" +version.workspace = true +authors = ["Dash Core Group "] +edition = "2021" +rust-version.workspace = true +license = "MIT" +description = "C++ binding of dash-sdk for embedders that supply their own quorum keys and signing wallet" + +[lib] +name = "dash_sdk_cxx" +crate-type = ["staticlib", "rlib"] + +[dependencies] +cxx = "1.0" +dash-sdk = { path = "../rs-sdk", default-features = false, features = [ + "dpns-contract", + "dashpay-contract", + "core_key_wallet", +] } +platform-encryption = { path = "../rs-platform-encryption" } +tokio = { version = "1.40", features = ["rt-multi-thread"] } +async-trait = "0.1" +zeroize = "1.8" + +[build-dependencies] +cxx-build = "1.0" diff --git a/packages/rs-sdk-cxx/build.rs b/packages/rs-sdk-cxx/build.rs new file mode 100644 index 00000000000..e2f850e7abe --- /dev/null +++ b/packages/rs-sdk-cxx/build.rs @@ -0,0 +1,37 @@ +use std::path::Path; +use std::{env, fs}; + +/// Generates the cxx bridge and stages the headers an embedder includes +/// under `target//include/`, next to the static archive: +/// `dash/sdk/ffi.h` (the generated bridge), `dash/sdk/callbacks.h` (the +/// hand-written C++ callback types the bridge names) and `rust/cxx.h`. +fn main() { + cxx_build::CFG.include_prefix = "dash/sdk"; + cxx_build::bridge("src/lib.rs") + .include("include") + .std("c++20") + .compile("dash-sdk-cxx-bridge"); + println!("cargo:rerun-if-changed=src/lib.rs"); + println!("cargo:rerun-if-changed=include/dash/sdk/callbacks.h"); + + let out_dir = env::var("OUT_DIR").expect("OUT_DIR"); + let include = Path::new(&out_dir) + .ancestors() + .nth(3) + .expect("target/") + .join("include"); + let generated = Path::new(&out_dir).join("cxxbridge/include"); + for (from, to) in [ + (generated.join("dash/sdk/src/lib.rs.h"), "dash/sdk/ffi.h"), + (generated.join("rust/cxx.h"), "rust/cxx.h"), + ( + Path::new("include/dash/sdk/callbacks.h").to_path_buf(), + "dash/sdk/callbacks.h", + ), + ] { + let to = include.join(to); + fs::create_dir_all(to.parent().expect("parent")).expect("create include dir"); + fs::copy(&from, &to) + .unwrap_or_else(|e| panic!("copy {} to {}: {e}", from.display(), to.display())); + } +} diff --git a/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h b/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h new file mode 100644 index 00000000000..ca4cef5f820 --- /dev/null +++ b/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h @@ -0,0 +1,180 @@ +// 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_SDK_CXX_CALLBACKS_H +#define DASH_SDK_CXX_CALLBACKS_H + +#include + +#include +#include +#include +#include +#include +#include +#include + +//! The C++ objects the SDK calls back into. Each wraps std::function +//! callbacks the embedder supplies, and each method is noexcept: a callback +//! that is empty, throws, or returns false is a refusal, and the SDK call it +//! serves fails. Answers are written into fixed-size buffers owned by Rust, +//! so nothing crosses the FFI by allocation. +//! +//! The SDK calls these on its own worker threads, possibly concurrently +//! with other calls on the same client, and may destroy them on any thread. +//! Callbacks must be thread-safe, take the locks they need themselves, and +//! must not wait on another call to the same client or on shutdown(). +//! Private keys never cross the FFI. +namespace dash_sdk { + +namespace detail { +//! Runs a callback body: a throw is a refusal. +template +bool Guard(Fn&& body) noexcept +{ + try { + return body(); + } catch (...) { + return false; + } +} +} // namespace detail + +//! Signs Platform state transitions with keys the wallet holds. +//! +//! SignIdentityKey: `key_id`, `key_type` and `key_data` describe the +//! identity key the SDK signs with, as Platform records it (key_data is the +//! 33-byte public key for ECDSA_SECP256K1 = 0, the 20-byte key hash for +//! ECDSA_HASH160 = 2). The wallet signs +//! SHA256d(signable) and answers a 65-byte compact recoverable signature. +//! +//! SignAssetLock: signs the 32-byte digest with the private key of the asset +//! lock's credit output. `key_ref` is the embedder's own handle, passed back +//! unchanged from register_identity / top_up_identity. The key is +//! compressed, so the header byte is 31 + recovery id. +class WalletSigner +{ +public: + using SignIdentityKeyFn = std::function key_data, + std::span signable, std::array& sig_out)>; + using SignAssetLockFn = + std::function& sighash, std::array& sig_out)>; + + WalletSigner(SignIdentityKeyFn sign_identity_key, SignAssetLockFn sign_asset_lock) + : m_sign_identity_key(std::move(sign_identity_key)), m_sign_asset_lock(std::move(sign_asset_lock)) + { + } + + bool SignIdentityKey(uint32_t key_id, uint8_t key_type, rust::Slice key_data, + rust::Slice signable, std::array& sig_out) const noexcept + { + return detail::Guard([&] { + return m_sign_identity_key && + m_sign_identity_key(key_id, key_type, {key_data.data(), key_data.size()}, + {signable.data(), signable.size()}, sig_out); + }); + } + + bool SignAssetLock(uint32_t key_ref, const std::array& sighash, + std::array& sig_out) const noexcept + { + return detail::Guard([&] { return m_sign_asset_lock && m_sign_asset_lock(key_ref, sighash, sig_out); }); + } + +private: + SignIdentityKeyFn m_sign_identity_key; + SignAssetLockFn m_sign_asset_lock; +}; + +//! The wallet's half of a DIP-15 contact request. +//! +//! SharedSecret: the ECDH secret between our identity key `sender_key_id` +//! and the recipient's compressed public key, as libsecp256k1's default +//! ECDH computes it: SHA256(0x02|0x03 by y parity || x). +//! +//! CompactXpub: the 69-byte DIP-15 compact extended public key +//! (parent fingerprint || chain code || public key) of the account the +//! wallet opens to `recipient` under `account_reference`. +class ContactKeys +{ +public: + using SharedSecretFn = std::function& recipient_key, + std::array& secret_out)>; + using CompactXpubFn = std::function& recipient, uint32_t account_reference, + std::array& xpub_out)>; + + ContactKeys(SharedSecretFn shared_secret, CompactXpubFn compact_xpub) + : m_shared_secret(std::move(shared_secret)), m_compact_xpub(std::move(compact_xpub)) + { + } + + bool SharedSecret(uint32_t sender_key_id, const std::array& recipient_key, + std::array& secret_out) const noexcept + { + return detail::Guard( + [&] { return m_shared_secret && m_shared_secret(sender_key_id, recipient_key, secret_out); }); + } + + bool CompactXpub(const std::array& recipient, uint32_t account_reference, + std::array& xpub_out) const noexcept + { + return detail::Guard( + [&] { return m_compact_xpub && m_compact_xpub(recipient, account_reference, xpub_out); }); + } + +private: + SharedSecretFn m_shared_secret; + CompactXpubFn m_compact_xpub; +}; + +//! The embedder's chain state, which the SDK verifies every proof against. +//! +//! QuorumPublicKey: the BLS public key of the quorum of type `llmq_type` +//! with hash `quorum_hash`, from the embedder's own mined commitments. The +//! hash is in the embedder's internal uint256 byte order. The client only +//! asks for its configured Platform LLMQ type; the callback must match both +//! type and hash and answer false for any quorum it does not hold. Any key +//! that is not exactly 48 bytes is refused. There is no fallback source. +//! +//! PlatformActivationHeight: the core height Platform activated at. +class CoreContext +{ +public: + using QuorumPublicKeyFn = std::function& quorum_hash, + uint32_t core_chain_locked_height, std::vector& key_out)>; + using PlatformActivationHeightFn = std::function; + + CoreContext(QuorumPublicKeyFn quorum_public_key, PlatformActivationHeightFn platform_activation_height) + : m_quorum_public_key(std::move(quorum_public_key)), + m_platform_activation_height(std::move(platform_activation_height)) + { + } + + bool QuorumPublicKey(uint8_t llmq_type, const std::array& quorum_hash, + uint32_t core_chain_locked_height, std::array& key_out) const noexcept + { + return detail::Guard([&] { + std::vector key; + if (!m_quorum_public_key || !m_quorum_public_key(llmq_type, quorum_hash, core_chain_locked_height, key) || + key.size() != key_out.size()) { + return false; + } + std::copy(key.begin(), key.end(), key_out.begin()); + return true; + }); + } + + bool PlatformActivationHeight(uint32_t& height_out) const noexcept + { + return detail::Guard([&] { return m_platform_activation_height && m_platform_activation_height(height_out); }); + } + +private: + QuorumPublicKeyFn m_quorum_public_key; + PlatformActivationHeightFn m_platform_activation_height; +}; + +} // namespace dash_sdk + +#endif // DASH_SDK_CXX_CALLBACKS_H diff --git a/packages/rs-sdk-cxx/scripts/cxx-smoke.sh b/packages/rs-sdk-cxx/scripts/cxx-smoke.sh new file mode 100755 index 00000000000..4727447157f --- /dev/null +++ b/packages/rs-sdk-cxx/scripts/cxx-smoke.sh @@ -0,0 +1,32 @@ +#!/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}" + +if [[ "${profile}" == "release" ]]; then + cargo build -p dash-sdk-cxx --locked --release +else + cargo build -p dash-sdk-cxx --locked +fi + +# The TLS stack reads the system trust store: Security.framework on macOS. +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-sdk-cxx.XXXXXX")" +trap 'rm -rf "${out}"' EXIT + +artifact_dir="${target_dir}/${profile}" +"${CXX:-c++}" -std=c++20 -I"${artifact_dir}/include" \ + "${package_dir}/tests/cxx_smoke.cc" \ + "${artifact_dir}/libdash_sdk_cxx.a" \ + "${system_libs[@]}" -o "${out}/cxx_smoke" +"${out}/cxx_smoke" diff --git a/packages/rs-sdk-cxx/src/client.rs b/packages/rs-sdk-cxx/src/client.rs new file mode 100644 index 00000000000..ac7792dba4a --- /dev/null +++ b/packages/rs-sdk-cxx/src/client.rs @@ -0,0 +1,719 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! The bridge's `PlatformClient`: one `Sdk` built from the embedder's +//! config, and the entry points, each a forward to one `dash-sdk` call run +//! on the binding's [`Runtime`]. + +use std::collections::BTreeMap; +use std::future::{ready, Future}; +use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; + +use dash_sdk::dapi_client::transport::{ProxyEndpoint, Socks5Auth, Socks5Proxy}; +use dash_sdk::dpp::consensus::codes::ErrorWithCode; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::v0::IdentityV0; +use dash_sdk::dpp::identity::{Identity, IdentityPublicKey}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::prelude::Identifier; +use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey}; +use dash_sdk::dpp::ProtocolError; +use dash_sdk::platform::dashpay::{ + ContactRequestInput, EcdhProvider, RecipientIdentity, SendContactRequestInput, +}; +use dash_sdk::platform::dpns_usernames::{convert_to_homograph_safe_chars, RegisterDpnsNameInput}; +use dash_sdk::platform::transition::put_identity::PutIdentity; +use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; +use dash_sdk::platform::types::identity::PublicKeyHash; +use dash_sdk::platform::Fetch; +use dash_sdk::sdk::{Address, AddressList}; +use dash_sdk::{Error, Sdk, SdkBuilder}; + +use crate::context::CoreContextProvider; +use crate::ffi::{self, Status, StatusKind}; +use crate::runtime::{blocking, lock, RunError, Runtime}; +use crate::signer::WalletSigner; +use crate::{guarded, marshal}; + +pub struct PlatformClient { + runtime: Runtime, + /// Taken by `shutdown`, once no call holds a clone. + sdk: Mutex>, +} + +impl Status { + fn new(kind: StatusKind, message: impl Into) -> Self { + Status { + kind, + consensus_code: 0, + message: message.into(), + } + } + + fn ok() -> Self { + Self::new(StatusKind::Ok, "") + } + + fn internal(message: impl Into) -> Self { + Self::new(StatusKind::Internal, message) + } + + fn not_found(what: impl Into) -> Self { + Self::new(StatusKind::NotFound, what) + } +} + +/// Lets `?` turn a failed input conversion into `Internal` ... +impl From for Status { + fn from(message: String) -> Self { + Status::internal(message) + } +} + +/// ... and an SDK error into its [`classify`] status. +impl From for Status { + fn from(error: Error) -> Self { + classify(&error) + } +} + +/// How an SDK error reads to the embedder. Only `Ok` and `NotFound` carry +/// a proof; everything else must be treated as "unknown". +pub fn classify(error: &Error) -> Status { + let kind = match error { + Error::NoAvailableAddressesToRetry(last) => return classify(last), + Error::Protocol(ProtocolError::ConsensusError(consensus)) => { + return Status { + consensus_code: consensus.code(), + ..Status::new(StatusKind::Consensus, consensus.to_string()) + } + } + Error::StateTransitionBroadcastError(broadcast) => { + return Status { + consensus_code: broadcast.code, + ..Status::new(StatusKind::Consensus, broadcast.message.clone()) + } + } + _ if error.is_quorum_source_unavailable() => StatusKind::Unavailable, + Error::DapiClientError(_) + | Error::TimeoutReached(..) + | Error::Cancelled(_) + | Error::StaleNode(_) => StatusKind::Unavailable, + Error::Proof(_) + | Error::DriveProofError(..) + | Error::InvalidProvedResponse(_) + | Error::ExecutionNotProved(_) + | Error::ContextProviderError(_) => StatusKind::Rejected, + _ => StatusKind::Internal, + }; + Status::new(kind, error.to_string()) +} + +/// One result struct per value type, each built from a call's outcome. +macro_rules! result_from { + ($result:ident { $field:ident: $value:ty = $empty:expr }) => { + impl From> for ffi::$result { + fn from(outcome: Result<$value, Status>) -> Self { + match outcome { + Ok($field) => ffi::$result { + status: Status::ok(), + $field, + }, + Err(status) => ffi::$result { + status, + $field: $empty, + }, + } + } + } + }; +} + +result_from!(IdentityResult { + identity: ffi::Identity = Default::default() +}); +result_from!(IdResult { + id: [u8; 32] = [0; 32] +}); +result_from!(BalanceResult { balance: u64 = 0 }); +result_from!(VoteStateResult { + state: ffi::VoteState = marshal::empty_vote_state() +}); +result_from!(ContestsResult { contests: Vec = Vec::new() }); +result_from!(ContactRequestsResult { requests: Vec = Vec::new() }); + +impl From, bool), Status>> for ffi::NamesResult { + fn from(outcome: Result<(Vec, bool), Status>) -> Self { + let (status, (names, complete)) = match outcome { + Ok(value) => (Status::ok(), value), + Err(status) => (status, (Vec::new(), false)), + }; + ffi::NamesResult { + status, + names, + complete, + } + } +} + +impl PlatformClient { + pub fn new( + config: &ffi::Config, + context: cxx::UniquePtr, + ) -> Result { + let network = match config.network { + ffi::Network::Mainnet => Network::Mainnet, + ffi::Network::Testnet => Network::Testnet, + ffi::Network::Devnet => Network::Devnet, + ffi::Network::Regtest => Network::Regtest, + other => return Err(format!("unknown network {}", other.repr)), + }; + let proxy = proxy(&config.proxy)?; + let addresses = config + .endpoints + .iter() + .map(|uri| endpoint(uri, proxy.is_some())) + .collect::>()?; + if context.is_null() { + return Err("a CoreContext is required".to_string()); + } + let provider = CoreContextProvider { + source: context, + platform_llmq_type: config.platform_llmq_type, + }; + let mut builder = SdkBuilder::new(addresses) + .with_network(network) + .with_context_provider(provider); + if let Some(proxy) = proxy { + builder = builder.with_proxy(proxy); + } + let runtime = Runtime::new()?; + let sdk = { + let _context = runtime.handle.enter(); + builder + .build() + .map_err(|e| format!("unable to build the SDK: {e}"))? + }; + Ok(PlatformClient { + runtime, + sdk: Mutex::new(Some(sdk)), + }) + } + + /// Runs the future `op` builds from the SDK on the runtime, blocking + /// until it returns. Input checks belong inside it: a client that is shut + /// down answers `Unavailable` whatever the input. + fn call(&self, what: &str, op: impl FnOnce(Sdk) -> Fut) -> Result + where + Fut: Future> + Send + 'static, + T: Send + 'static, + { + guarded( + what, + || { + let shut_down = || Status::new(StatusKind::Unavailable, "client is shut down"); + // Held until the SDK clone and the future are gone. + let call = self.runtime.call().ok_or_else(shut_down)?; + let sdk = lock(&self.sdk).clone().ok_or_else(shut_down)?; + match call.run(op(sdk)) { + Ok(outcome) => outcome, + Err(RunError::ShutDown) => Err(shut_down()), + Err(RunError::Panicked(message)) => Err(message.into()), + } + }, + |message| Err(message.into()), + ) + } + + /// Cancels without cloning the SDK, so the one this client holds is its + /// last owner: dropped, with the `CoreContext`, inside the serialized + /// runtime shutdown once every call has ended (and inside the runtime + /// context, as tonic's pooled channels expect). + pub fn shutdown(&self) { + guarded( + "shutdown", + || { + if let Some(sdk) = lock(&self.sdk).as_ref() { + sdk.shutdown(); + } + self.runtime.shutdown(|| drop(lock(&self.sdk).take())); + }, + |_| (), + ) + } + + pub fn get_identity(&self, id: &[u8; 32]) -> ffi::IdentityResult { + self.call("get_identity", |sdk| { + let id = Identifier::from(*id); + async move { fetched_identity(Identity::fetch(&sdk, id).await) } + }) + .into() + } + + pub fn get_identity_by_pubkey_hash(&self, pubkey_hash: &[u8; 20]) -> ffi::IdentityResult { + self.call("get_identity_by_pubkey_hash", |sdk| { + let hash = PublicKeyHash(*pubkey_hash); + async move { fetched_identity(Identity::fetch(&sdk, hash).await) } + }) + .into() + } + + pub fn resolve_name(&self, name: &str) -> ffi::IdResult { + self.call("resolve_name", |sdk| { + let name = name.to_string(); + async move { + sdk.resolve_dpns_name(&name) + .await? + .map(|id| id.to_buffer()) + .ok_or_else(|| Status::not_found(format!("no DPNS name {name}"))) + } + }) + .into() + } + + pub fn names_of_identity( + &self, + identity: &[u8; 32], + page_limit: u32, + max_pages: u32, + ) -> ffi::NamesResult { + self.call("names_of_identity", |sdk| { + let identity = Identifier::from(*identity); + async move { + if page_limit == 0 || max_pages == 0 { + return Err(Status::internal("page_limit and max_pages must be >= 1")); + } + let (names, complete) = sdk + .get_all_dpns_usernames_by_identity(identity, page_limit, max_pages as usize) + .await?; + Ok(( + names.into_iter().map(marshal::dpns_name).collect(), + complete, + )) + } + }) + .into() + } + + /// `complete` is always false: the SDK drops unparseable documents after + /// the fetch and does not report the raw page length, so even a short + /// result does not prove the end. + pub fn search_names(&self, prefix: &str, limit: u32) -> ffi::NamesResult { + self.call("search_names", |sdk| { + let prefix = prefix.to_string(); + async move { + if limit == 0 { + return Err(Status::internal("limit must be >= 1")); + } + let names = sdk.search_dpns_names(&prefix, Some(limit)).await?; + Ok((names.into_iter().map(marshal::dpns_name).collect(), false)) + } + }) + .into() + } + + pub fn pending_contests_for_identity(&self, identity: &[u8; 32]) -> ffi::ContestsResult { + self.call("pending_contests_for_identity", |sdk| { + let identity = Identifier::from(*identity); + async move { + let contests = sdk + .get_non_resolved_dpns_contests_for_identity(identity, None) + .await?; + Ok(contests + .into_iter() + .map(|(name, info)| ffi::Contest { + name, + ends_at_ms: info.end_time, + state: marshal::vote_state(&info.contenders), + }) + .collect()) + } + }) + .into() + } + + pub fn contested_vote_state(&self, label: &str) -> ffi::VoteStateResult { + self.call("contested_vote_state", |sdk| { + // The SDK's contested query takes the index value as is. + let label = convert_to_homograph_safe_chars(label); + async move { + let contenders = sdk.get_contested_dpns_vote_state(&label, None).await?; + Ok(marshal::vote_state(&contenders)) + } + }) + .into() + } + + pub fn contact_requests( + &self, + identity: &[u8; 32], + direction: ffi::ContactDirection, + since_ms: u64, + ) -> ffi::ContactRequestsResult { + self.call("contact_requests", |sdk| { + let identity = Identifier::from(*identity); + let since = (since_ms != 0).then_some(since_ms); + async move { + let documents = match direction { + ffi::ContactDirection::Sent => { + sdk.fetch_sent_contact_requests(identity, since).await + } + ffi::ContactDirection::Received => { + sdk.fetch_received_contact_requests(identity, since).await + } + other => { + return Err(Status::internal(format!( + "unknown direction {}", + other.repr + ))) + } + }?; + documents + .values() + .flatten() + .map(marshal::contact_request) + .collect::>() + .map_err(|e| Status::new(StatusKind::Rejected, e)) + } + }) + .into() + } + + pub fn register_identity( + &self, + keys: &[ffi::IdentityKey], + proof: &ffi::AssetLockProof, + asset_lock_key_ref: u32, + signer: cxx::UniquePtr, + ) -> ffi::IdentityResult { + self.call("register_identity", |sdk| { + let public_keys = keys + .iter() + .map(|key| marshal::new_identity_key(key).map(|key| (key.id(), key))) + .collect::, String>>() + .and_then(|public_keys| { + (public_keys.len() == keys.len()) + .then_some(public_keys) + .ok_or_else(|| "duplicate identity key ids".to_string()) + }); + let proof = marshal::asset_lock_proof(proof); + async move { + // The id is derived from the asset lock when the transition is built. + let identity = bare_identity(Identifier::default(), public_keys?); + let signer = WalletSigner::new(signer)?; + let created = identity + .put_to_platform_and_wait_for_response_with_signer( + &sdk, + proof?, + &DerivationPath::master(), + &signer.asset_lock(asset_lock_key_ref), + &signer, + None, + ) + .await?; + Ok(marshal::identity(&created)) + } + }) + .into() + } + + pub fn top_up_identity( + &self, + identity: &[u8; 32], + proof: &ffi::AssetLockProof, + asset_lock_key_ref: u32, + signer: cxx::UniquePtr, + ) -> ffi::BalanceResult { + self.call("top_up_identity", |sdk| { + // A top-up names only the identity's id. + let identity = bare_identity(Identifier::from(*identity), BTreeMap::new()); + let proof = marshal::asset_lock_proof(proof); + async move { + let signer = WalletSigner::new(signer)?; + Ok(identity + .top_up_identity_with_signer( + &sdk, + proof?, + &DerivationPath::master(), + &signer.asset_lock(asset_lock_key_ref), + None, + ) + .await?) + } + }) + .into() + } + + pub fn register_dpns_name( + &self, + label: &str, + identity: &[u8; 32], + key_id: u32, + contest_fund: u64, + signer: cxx::UniquePtr, + ) -> ffi::IdResult { + self.call("register_dpns_name", |sdk| { + let label = label.to_string(); + let identity = Identifier::from(*identity); + async move { + let signer = WalletSigner::new(signer)?; + let (identity, key) = identity_and_key(&sdk, identity, key_id).await?; + let registered = sdk + .register_dpns_name(RegisterDpnsNameInput { + label, + identity, + identity_public_key: key, + signer, + preorder_callback: None, + contest_fund: (contest_fund != 0).then_some(contest_fund), + }) + .await?; + Ok(registered.domain_document.id().to_buffer()) + } + }) + .into() + } + + pub fn send_contact_request( + &self, + input: &ffi::ContactRequestInput, + signer: cxx::UniquePtr, + keys: cxx::UniquePtr, + ) -> ffi::IdResult { + self.call("send_contact_request", |sdk| { + let input = input.clone(); + async move { + let signer = WalletSigner::new(signer)?; + if keys.is_null() { + return Err(Status::internal("ContactKeys are required")); + } + let ecdh_keys = Arc::new(keys); + let xpub_keys = Arc::clone(&ecdh_keys); + let sender_key = input.sender_key_index; + let recipient = input.recipient; + let ecdh = EcdhProvider::::ClientSide { + get_shared_secret: move |recipient_key: &PublicKey| { + let mut secret = [0u8; 32]; + let answered = blocking(|| { + ecdh_keys.SharedSecret( + sender_key, + &recipient_key.serialize(), + &mut secret, + ) + }); + ready(answered.then_some(secret).ok_or_else(|| { + Error::Generic("the wallet refused the ECDH".to_string()) + })) + }, + }; + let xpub = move |account_reference: u32| { + let mut xpub = [0u8; 69]; + let answered = blocking(|| { + xpub_keys.CompactXpub(&recipient, account_reference, &mut xpub) + }); + ready(answered.then(|| xpub.to_vec()).ok_or_else(|| { + Error::Generic("the wallet refused the contact xpub".to_string()) + })) + }; + let (sender, key) = + identity_and_key(&sdk, Identifier::from(input.sender), input.signing_key_id) + .await?; + let request = ContactRequestInput { + sender_identity: sender, + recipient: RecipientIdentity::Identifier(Identifier::from(input.recipient)), + sender_key_index: input.sender_key_index, + recipient_key_index: input.recipient_key_index, + account_reference: input.account_reference, + account_label: (!input.account_label.is_empty()).then_some(input.account_label), + auto_accept_proof: None, + }; + let sent = sdk + .send_contact_request( + SendContactRequestInput { + contact_request: request, + identity_public_key: key, + signer, + }, + ecdh, + xpub, + ) + .await?; + Ok(sent.document.id().to_buffer()) + } + }) + .into() + } +} + +impl Drop for PlatformClient { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// The SDK-side ECDH branch, never used: the wallet derives the secret. +type NoPrivateKey = fn(&IdentityPublicKey, u32) -> std::future::Ready>; + +fn fetched_identity(fetched: Result, Error>) -> Result { + fetched? + .map(|identity| marshal::identity(&identity)) + .ok_or_else(|| Status::not_found("no such identity")) +} + +fn bare_identity(id: Identifier, public_keys: BTreeMap) -> Identity { + IdentityV0 { + id, + public_keys, + balance: 0, + revision: 0, + } + .into() +} + +/// The identity and its key `key_id`, as Platform holds them now. +async fn identity_and_key( + sdk: &Sdk, + id: Identifier, + key_id: u32, +) -> Result<(Identity, IdentityPublicKey), Status> { + let identity = Identity::fetch(sdk, id) + .await? + .ok_or_else(|| Status::not_found(format!("no identity {id}")))?; + let key = identity + .public_keys() + .get(&key_id) + .cloned() + .ok_or_else(|| Status::internal(format!("identity {id} has no key {key_id}")))?; + Ok((identity, key)) +} + +/// The embedder's proxy setting. Anything malformed is an error, so a +/// client never connects directly when a proxy was meant. +fn proxy(cfg: &ffi::Proxy) -> Result, String> { + let endpoint = match cfg.kind { + ffi::ProxyKind::None if cfg.address.is_empty() => return Ok(None), + ffi::ProxyKind::None => return Err("a proxy address without a proxy kind".to_string()), + ffi::ProxyKind::Tcp => ProxyEndpoint::Tcp( + SocketAddr::from_str(&cfg.address) + .map_err(|_| format!("proxy {:?} is not a numeric ip:port", cfg.address))?, + ), + #[cfg(unix)] + ffi::ProxyKind::Unix if !cfg.address.is_empty() => { + ProxyEndpoint::Unix(cfg.address.clone().into()) + } + other => { + return Err(format!( + "unusable proxy kind {} {:?}", + other.repr, cfg.address + )) + } + }; + let auth = if cfg.isolate { + Socks5Auth::RandomPerConnection + } else { + Socks5Auth::None + }; + Ok(Some(Socks5Proxy { endpoint, auth })) +} + +/// An evonode endpoint: `https`, and a literal IP, or an onion name when a +/// proxy can reach it. Nothing is resolved locally. +fn endpoint(uri: &str, proxied: bool) -> Result { + let address = Address::from_str(uri).map_err(|e| format!("bad endpoint {uri}: {e}"))?; + if address.uri().scheme_str() != Some("https") { + return Err(format!("bad endpoint {uri}: not https")); + } + let host = address.uri().host().unwrap_or_default(); + let ip = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host); + if ip.parse::().is_ok() || (proxied && host.ends_with(".onion")) { + Ok(address) + } else { + Err(format!( + "bad endpoint {uri}: the host must be an IP address, or an onion name with a proxy" + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::dpp::consensus::ConsensusError; + use dash_sdk::error::{ContextProviderError, StateTransitionBroadcastError}; + + /// Only a proof yields `Ok` or `NotFound`; consensus refusals keep + /// their code; nothing else may read as either. + #[test] + fn sdk_errors_map_to_the_status_table() { + let status = classify(&Error::Protocol(ProtocolError::ConsensusError(Box::new( + ConsensusError::DefaultError, + )))); + assert_eq!(status.kind, StatusKind::Consensus); + let status = classify(&Error::StateTransitionBroadcastError( + StateTransitionBroadcastError { + code: 40100, + message: "refused".to_string(), + cause: None, + }, + )); + assert_eq!( + (status.kind, status.consensus_code), + (StatusKind::Consensus, 40100) + ); + + let quorum = Error::ContextProviderError; + for (error, kind) in [ + ( + Error::DapiClientError(DapiClientError::NoAvailableAddresses), + StatusKind::Unavailable, + ), + ( + Error::TimeoutReached(std::time::Duration::from_secs(1), "t".into()), + StatusKind::Unavailable, + ), + ( + quorum(ContextProviderError::QuorumSourceUnavailable("x".into())), + StatusKind::Unavailable, + ), + ( + quorum(ContextProviderError::InvalidQuorum("x".into())), + StatusKind::Rejected, + ), + ( + Error::NoAvailableAddressesToRetry(Box::new(Error::InvalidProvedResponse( + "x".into(), + ))), + StatusKind::Rejected, + ), + (Error::Generic("x".into()), StatusKind::Internal), + ] { + assert_eq!(classify(&error).kind, kind, "{error}"); + } + } + + /// Tor stream isolation; the C++ smoke covers the refusals. + #[test] + fn isolate_asks_for_fresh_proxy_credentials() { + let proxy = |isolate| { + super::proxy(&ffi::Proxy { + kind: ffi::ProxyKind::Tcp, + address: "127.0.0.1:9050".to_string(), + isolate, + }) + .unwrap() + .unwrap() + .auth + }; + assert_eq!(proxy(true), Socks5Auth::RandomPerConnection); + assert_eq!(proxy(false), Socks5Auth::None); + } +} diff --git a/packages/rs-sdk-cxx/src/context.rs b/packages/rs-sdk-cxx/src/context.rs new file mode 100644 index 00000000000..9831606c481 --- /dev/null +++ b/packages/rs-sdk-cxx/src/context.rs @@ -0,0 +1,214 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! The SDK's [`ContextProvider`] over the embedder's `CoreContext`: quorum +//! keys are pulled from the embedder on every proof, with no cache and no +//! other source; DPNS and DashPay contracts come from dpp. + +use std::sync::Arc; + +use dash_sdk::dpp::data_contract::TokenConfiguration; +use dash_sdk::dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; +use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::error::ContextProviderError; +use dash_sdk::platform::ContextProvider; + +use crate::ffi; +use crate::marshal::reversed; +use crate::runtime::blocking; + +// SAFETY: `CoreContext` is documented as callable from any thread; its +// callbacks take the embedder's own locks. +unsafe impl Send for ffi::CoreContext {} +unsafe impl Sync for ffi::CoreContext {} + +/// Where quorum keys come from: the embedder's `CoreContext`, or a fake in +/// tests. Both answer `None` for anything they do not hold. +pub trait QuorumSource: Send + Sync { + fn quorum_public_key(&self, llmq_type: u8, hash: &[u8; 32], height: u32) -> Option<[u8; 48]>; + fn platform_activation_height(&self) -> Option; +} + +impl QuorumSource for cxx::UniquePtr { + fn quorum_public_key(&self, llmq_type: u8, hash: &[u8; 32], height: u32) -> Option<[u8; 48]> { + let mut key = [0u8; 48]; + self.QuorumPublicKey(llmq_type, hash, height, &mut key) + .then_some(key) + } + + fn platform_activation_height(&self) -> Option { + let mut height = 0; + self.PlatformActivationHeight(&mut height).then_some(height) + } +} + +pub struct CoreContextProvider { + pub source: S, + pub platform_llmq_type: u8, +} + +impl ContextProvider for CoreContextProvider { + fn get_data_contract( + &self, + id: &Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + [SystemDataContract::DPNS, SystemDataContract::Dashpay] + .into_iter() + .find(|contract| contract.id() == *id) + .map(|contract| { + load_system_data_contract(contract, platform_version) + .map(Arc::new) + .map_err(|e| ContextProviderError::DataContractFailure(e.to_string())) + }) + .transpose() + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + + /// `quorum_hash` arrives in proof byte order, the reverse of the + /// embedder's internal `uint256` order. The type is range-checked before + /// it is narrowed and must be the configured Platform type. + fn get_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + if quorum_type != u32::from(self.platform_llmq_type) { + return Err(ContextProviderError::InvalidQuorum(format!( + "proof signed by LLMQ type {quorum_type}; Platform uses type {}", + self.platform_llmq_type + ))); + } + let internal_order = reversed(&quorum_hash); + blocking(|| { + self.source.quorum_public_key( + self.platform_llmq_type, + &internal_order, + core_chain_locked_height, + ) + }) + .ok_or_else(|| { + ContextProviderError::InvalidQuorum( + "no local Platform quorum with that hash".to_string(), + ) + }) + } + + /// A refusal is the local source's gap, not a bad proof. (Its only SDK + /// consumer, total credits, is not bridged.) + fn get_platform_activation_height(&self) -> Result { + blocking(|| self.source.platform_activation_height()).ok_or_else(|| { + ContextProviderError::QuorumSourceUnavailable( + "the Platform activation height is unknown".to_string(), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + const PLATFORM_LLMQ: u8 = 106; + + /// Holds one key, under its hash in the embedder's byte order, and + /// records every lookup it is asked for. + struct OneQuorum { + hash: [u8; 32], + asked: Mutex>, + } + + impl QuorumSource for OneQuorum { + fn quorum_public_key( + &self, + llmq_type: u8, + hash: &[u8; 32], + height: u32, + ) -> Option<[u8; 48]> { + self.asked.lock().unwrap().push((llmq_type, *hash, height)); + (*hash == self.hash).then_some([7; 48]) + } + + fn platform_activation_height(&self) -> Option { + None + } + } + + fn provider(hash: [u8; 32]) -> CoreContextProvider { + CoreContextProvider { + source: OneQuorum { + hash, + asked: Mutex::new(Vec::new()), + }, + platform_llmq_type: PLATFORM_LLMQ, + } + } + + #[test] + fn quorum_lookups_check_the_type_and_pass_the_hash_in_core_order() { + let mut core_order = [0u8; 32]; + core_order[0] = 0xaa; + let provider = provider(core_order); + let mut proof_order = core_order; + proof_order.reverse(); + let platform = u32::from(PLATFORM_LLMQ); + + assert_eq!( + provider + .get_quorum_public_key(platform, proof_order, 9) + .unwrap(), + [7; 48] + ); + // Unknown to the embedder: refused, with no fallback. + assert!(matches!( + provider.get_quorum_public_key(platform, core_order, 9), + Err(ContextProviderError::InvalidQuorum(_)) + )); + // Another type, including one that would wrap to the Platform type + // when narrowed to u8, never reaches the embedder. + for other in [platform + 1, platform + 256, u32::MAX] { + assert!(matches!( + provider.get_quorum_public_key(other, proof_order, 9), + Err(ContextProviderError::InvalidQuorum(_)) + )); + } + assert_eq!( + *provider.source.asked.lock().unwrap(), + vec![ + (PLATFORM_LLMQ, core_order, 9), + (PLATFORM_LLMQ, proof_order, 9) + ] + ); + assert!(matches!( + provider.get_platform_activation_height(), + Err(ContextProviderError::QuorumSourceUnavailable(_)) + )); + } + + #[test] + fn system_contracts_are_served_locally() { + let provider = provider([0; 32]); + let version = PlatformVersion::latest(); + for contract in [SystemDataContract::DPNS, SystemDataContract::Dashpay] { + assert!(provider + .get_data_contract(&contract.id(), version) + .unwrap() + .is_some()); + } + let unknown = Identifier::from([0x33u8; 32]); + assert!(provider + .get_data_contract(&unknown, version) + .unwrap() + .is_none()); + } +} diff --git a/packages/rs-sdk-cxx/src/lib.rs b/packages/rs-sdk-cxx/src/lib.rs new file mode 100644 index 00000000000..69529d9f184 --- /dev/null +++ b/packages/rs-sdk-cxx/src/lib.rs @@ -0,0 +1,523 @@ +// 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. + +//! A C++ binding of `dash-sdk` for an embedder that supplies its own +//! endpoints, quorum keys ([`context`]) and wallet ([`signer`]). Each entry +//! point ([`client`]) forwards to `dash-sdk` and returns a [`ffi::Status`]. +//! No panic reaches C++: every entry point runs under [`guarded`]. + +#[cfg(panic = "abort")] +compile_error!("dash-sdk-cxx requires panic = \"unwind\"; its FFI guards rely on catch_unwind"); + +mod client; +mod context; +mod marshal; +mod runtime; +mod signer; + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use client::PlatformClient; + +#[cxx::bridge(namespace = "dash_sdk")] +pub mod ffi { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum Network { + Mainnet = 0, + Testnet = 1, + Devnet = 2, + Regtest = 3, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum ProxyKind { + /// Connect directly; `address` must be empty. + None = 0, + /// SOCKS5 at a numeric `ip:port` (IPv6 in brackets). + Tcp = 1, + /// SOCKS5 on the Unix socket at path `address`. + Unix = 2, + } + + /// The SOCKS5 proxy every Platform connection goes through, with no + /// direct fallback. `isolate`: fresh random credentials per connection, + /// so Tor gives each its own circuit. + #[derive(Debug, Clone)] + struct Proxy { + kind: ProxyKind, + address: String, + isolate: bool, + } + + /// `platform_llmq_type`: the only LLMQ type proofs may be signed by. + /// `endpoints`: `https://host:port` evonode addresses, the host a + /// literal IP, or an onion name when a proxy is set. Nothing is resolved + /// locally. Fixed for the client's lifetime. + #[derive(Debug, Clone)] + struct Config { + network: Network, + platform_llmq_type: u8, + proxy: Proxy, + endpoints: Vec, + } + + /// The outcome of a call. A result's value is meaningful only under + /// `Ok`; `message` is for logs. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum StatusKind { + Ok = 0, + /// A proof shows the object does not exist. + NotFound = 1, + /// Nothing was proved: transport failure, timeout, proxy failure, + /// no usable endpoint, or the client is shut down. + Unavailable = 2, + /// A response failed verification (proof, quorum, staleness) or a + /// node refused the request. + Rejected = 3, + /// Platform rejected a state transition; see `consensus_code`. + Consensus = 4, + /// Bad input from the embedder, a refused callback, or a bug. + Internal = 5, + } + + #[derive(Debug, Clone)] + struct Status { + kind: StatusKind, + consensus_code: u32, + message: String, + } + + /// An identity public key; `disabled_at == 0` means enabled. + #[derive(Debug, Clone, Default)] + struct IdentityKey { + id: u32, + purpose: u8, + security_level: u8, + key_type: u8, + read_only: bool, + data: Vec, + disabled_at: u64, + } + + #[derive(Debug, Clone, Default)] + struct Identity { + id: [u8; 32], + balance: u64, + revision: u64, + keys: Vec, + } + + #[derive(Debug, Clone)] + struct IdentityResult { + status: Status, + identity: Identity, + } + + #[derive(Debug, Clone)] + struct IdResult { + status: Status, + id: [u8; 32], + } + + #[derive(Debug, Clone)] + struct BalanceResult { + status: Status, + balance: u64, + } + + /// A DPNS name. `identity` is the identity it resolves to, all zero + /// when its record names none. + #[derive(Debug, Clone, Default)] + struct DpnsName { + label: String, + normalized_label: String, + owner: [u8; 32], + identity: [u8; 32], + } + + /// `complete`: true only when the SDK proved the end of the list. + #[derive(Debug, Clone)] + struct NamesResult { + status: Status, + names: Vec, + complete: bool, + } + + #[derive(Debug, Clone, Default)] + struct Contender { + identity: [u8; 32], + votes: u32, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum WinnerKind { + /// Still open, or not finished. + None = 0, + Identity = 1, + Locked = 2, + } + + #[derive(Debug, Clone)] + struct VoteState { + contenders: Vec, + abstain: u32, + lock: u32, + winner_kind: WinnerKind, + winner: [u8; 32], + } + + #[derive(Debug, Clone)] + struct VoteStateResult { + status: Status, + state: VoteState, + } + + /// A contested DPNS name: its normalized label, end time and state. + #[derive(Debug, Clone)] + struct Contest { + name: String, + ends_at_ms: u64, + state: VoteState, + } + + #[derive(Debug, Clone)] + struct ContestsResult { + status: Status, + contests: Vec, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum ContactDirection { + Sent = 0, + Received = 1, + } + + /// A DashPay `contactRequest` document. Empty vectors mean the field is + /// absent. + #[derive(Debug, Clone, Default)] + struct ContactRequest { + document_id: [u8; 32], + owner: [u8; 32], + to_user_id: [u8; 32], + encrypted_public_key: Vec, + sender_key_index: u32, + recipient_key_index: u32, + account_reference: u32, + encrypted_account_label: Vec, + auto_accept_proof: Vec, + created_at_ms: u64, + created_at_core_height: u32, + } + + #[derive(Debug, Clone)] + struct ContactRequestsResult { + status: Status, + requests: Vec, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum AssetLockProofKind { + /// `transaction` and `instant_lock` consensus-encoded; the credit + /// output at `output_index`. + Instant = 0, + /// `out_point` (txid || vout, consensus-encoded) ChainLocked at or + /// below `core_chain_locked_height`. + Chain = 1, + } + + #[derive(Debug, Clone)] + struct AssetLockProof { + kind: AssetLockProofKind, + transaction: Vec, + instant_lock: Vec, + output_index: u32, + core_chain_locked_height: u32, + out_point: [u8; 36], + } + + /// `signing_key_id`: the sender's key that signs the document. + /// `account_label` empty = none. + #[derive(Debug, Clone)] + struct ContactRequestInput { + sender: [u8; 32], + recipient: [u8; 32], + sender_key_index: u32, + recipient_key_index: u32, + signing_key_id: u32, + account_reference: u32, + account_label: String, + } + + /// The DIP-15 compact extended public key, unvalidated beyond its + /// length: the embedder checks `public_key` is a valid point. + #[derive(Debug, Clone)] + struct CompactXpub { + parent_fingerprint: [u8; 4], + chain_code: [u8; 32], + public_key: [u8; 33], + } + + unsafe extern "C++" { + include!("dash/sdk/callbacks.h"); + + type WalletSigner; + type ContactKeys; + type CoreContext; + + fn SignIdentityKey( + self: &WalletSigner, + key_id: u32, + key_type: u8, + key_data: &[u8], + signable: &[u8], + sig_out: &mut [u8; 65], + ) -> bool; + fn SignAssetLock( + self: &WalletSigner, + key_ref: u32, + sighash: &[u8; 32], + sig_out: &mut [u8; 65], + ) -> bool; + + fn SharedSecret( + self: &ContactKeys, + sender_key_id: u32, + recipient_key: &[u8; 33], + secret_out: &mut [u8; 32], + ) -> bool; + fn CompactXpub( + self: &ContactKeys, + recipient: &[u8; 32], + account_reference: u32, + xpub_out: &mut [u8; 69], + ) -> bool; + + fn QuorumPublicKey( + self: &CoreContext, + llmq_type: u8, + quorum_hash: &[u8; 32], + core_chain_locked_height: u32, + key_out: &mut [u8; 48], + ) -> bool; + fn PlatformActivationHeight(self: &CoreContext, height_out: &mut u32) -> bool; + } + + extern "Rust" { + /// One SDK instance, its runtime and its `CoreContext`. Thread-safe: + /// calls may run concurrently, each blocking its calling thread. + type PlatformClient; + + /// A rust::Error for a config it cannot honour: never a direct fallback. + fn new_platform_client( + config: &Config, + context: UniquePtr, + ) -> Result>; + /// Refuses new calls, cancels the running ones and waits for every + /// one to return, so no callback runs once it returns; the + /// CoreContext is destroyed before it returns. Later calls are + /// `Unavailable`. Idempotent; dropping the client calls it. Must not + /// be called from a callback, or while holding a lock a callback + /// takes. + fn shutdown(self: &PlatformClient); + + // Reads: every value is verified against the CoreContext. + fn get_identity(self: &PlatformClient, id: &[u8; 32]) -> IdentityResult; + fn get_identity_by_pubkey_hash( + self: &PlatformClient, + pubkey_hash: &[u8; 20], + ) -> IdentityResult; + /// The identity a DPNS name (label or `label.dash`) resolves to. + /// `NotFound` means "does not resolve", not "available": a contested + /// name has no domain until its contest ends. + fn resolve_name(self: &PlatformClient, name: &str) -> IdResult; + /// Reads at most `max_pages` pages of `page_limit` names (both >= 1). + fn names_of_identity( + self: &PlatformClient, + identity: &[u8; 32], + page_limit: u32, + max_pages: u32, + ) -> NamesResult; + /// Up to `limit` (>= 1) names whose normalized label starts with + /// `prefix`. `complete` is always false: the SDK does not report + /// whether it reached the end. + fn search_names(self: &PlatformClient, prefix: &str, limit: u32) -> NamesResult; + /// Unresolved contests `identity` takes part in; best effort, the + /// SDK examines at most 100 contests. + fn pending_contests_for_identity( + self: &PlatformClient, + identity: &[u8; 32], + ) -> ContestsResult; + /// The contest for a DPNS label (normalized here). + fn contested_vote_state(self: &PlatformClient, label: &str) -> VoteStateResult; + /// Every request sent by or to `identity`, created after `since_ms` + /// (0 = all). + fn contact_requests( + self: &PlatformClient, + identity: &[u8; 32], + direction: ContactDirection, + since_ms: u64, + ) -> ContactRequestsResult; + + // Writes: one end-to-end SDK call each, waiting for the proved + // result. Callback objects are moved in. + /// Creates the identity holding `keys` (each must be enabled). + fn register_identity( + self: &PlatformClient, + keys: &[IdentityKey], + proof: &AssetLockProof, + asset_lock_key_ref: u32, + signer: UniquePtr, + ) -> IdentityResult; + /// The identity's balance after the top-up. + fn top_up_identity( + self: &PlatformClient, + identity: &[u8; 32], + proof: &AssetLockProof, + asset_lock_key_ref: u32, + signer: UniquePtr, + ) -> BalanceResult; + /// Preorder then domain, signed with `key_id`; the domain document + /// id. `contest_fund`: the most a contested name may pay into its + /// contest; 0 lets the SDK price it just before the domain is sent. + fn register_dpns_name( + self: &PlatformClient, + label: &str, + identity: &[u8; 32], + key_id: u32, + contest_fund: u64, + signer: UniquePtr, + ) -> IdResult; + /// The contact request document id. + fn send_contact_request( + self: &PlatformClient, + input: &ContactRequestInput, + signer: UniquePtr, + keys: UniquePtr, + ) -> IdResult; + + // No network. A panic answers the safe value: not valid, contested. + fn is_valid_username(label: &str) -> bool; + fn is_contested_username(label: &str) -> bool; + /// Decrypts `encryptedPublicKey` with the wallet's ECDH secret. + fn decrypt_contact_xpub( + shared_secret: &[u8; 32], + encrypted_public_key: &[u8], + ) -> Result; + } +} + +/// Runs a bridge body; a panic becomes the value `on_panic` builds from its +/// message instead of the process abort cxx would perform. +pub(crate) fn guarded( + what: &str, + body: impl FnOnce() -> T, + on_panic: impl FnOnce(String) -> T, +) -> T { + catch_unwind(AssertUnwindSafe(body)).unwrap_or_else(|payload| { + let message = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or("non-string panic payload"); + on_panic(format!("{what} panicked: {message}")) + }) +} + +fn new_platform_client( + config: &ffi::Config, + context: cxx::UniquePtr, +) -> Result, String> { + guarded( + "new_platform_client", + || PlatformClient::new(config, context).map(Box::new), + Err, + ) +} + +fn is_valid_username(label: &str) -> bool { + guarded( + "is_valid_username", + || dash_sdk::platform::dpns_usernames::is_valid_username(label), + |_| false, + ) +} + +fn is_contested_username(label: &str) -> bool { + contested( + dash_sdk::platform::dpns_usernames::is_contested_username, + label, + ) +} + +/// Fails closed: a contested answer keeps Core off the ordinary-name path. +fn contested(check: fn(&str) -> bool, label: &str) -> bool { + guarded("is_contested_username", || check(label), |_| true) +} + +fn decrypt_contact_xpub( + shared_secret: &[u8; 32], + encrypted_public_key: &[u8], +) -> Result { + guarded( + "decrypt_contact_xpub", + || { + let secret = zeroize::Zeroizing::new(*shared_secret); + let plain = zeroize::Zeroizing::new( + platform_encryption::decrypt_extended_public_key(&secret, encrypted_public_key) + .map_err(|e| format!("cannot decrypt the contact xpub: {e}"))?, + ); + let xpub = platform_encryption::parse_compact_xpub(&plain) + .map_err(|e| format!("not a DIP-15 compact xpub: {e}"))?; + Ok(ffi::CompactXpub { + parent_fingerprint: xpub.parent_fingerprint, + chain_code: xpub.chain_code, + public_key: xpub.public_key, + }) + }, + Err, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_panic_becomes_the_fallback_value() { + let value = guarded( + "probe", + || -> u8 { panic!("hostile bytes") }, + |m| { + assert!(m.contains("probe panicked: hostile bytes"), "{m}"); + 7 + }, + ); + assert_eq!(value, 7); + assert!(contested(|_| panic!("hostile label"), "alice")); + } + + /// The xpub is returned as decrypted: the public key is not normalized + /// or validated, so the embedder checks the point itself. + #[test] + fn a_contact_xpub_round_trips_unmodified() { + let secret = [5u8; 32]; + let mut plain = [0u8; platform_encryption::COMPACT_XPUB_LEN]; + for (i, byte) in plain.iter_mut().enumerate() { + *byte = i as u8; + } + let blob = platform_encryption::encrypt_extended_public_key(&secret, &[3; 16], &plain); + let xpub = decrypt_contact_xpub(&secret, &blob).expect("decrypts"); + assert_eq!(xpub.parent_fingerprint, plain[..4]); + assert_eq!(xpub.chain_code, plain[4..36]); + assert_eq!(xpub.public_key, plain[36..]); + assert!(decrypt_contact_xpub(&[6; 32], &blob).is_err()); + assert!(decrypt_contact_xpub(&secret, &blob[..20]).is_err()); + } +} diff --git a/packages/rs-sdk-cxx/src/marshal.rs b/packages/rs-sdk-cxx/src/marshal.rs new file mode 100644 index 00000000000..810d14d8d6b --- /dev/null +++ b/packages/rs-sdk-cxx/src/marshal.rs @@ -0,0 +1,232 @@ +// 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. + +//! Conversions between the bridge's plain structs and dpp types. + +use std::collections::BTreeMap; + +use dash_sdk::dpp::dashcore::consensus::Decodable; +use dash_sdk::dpp::dashcore::{InstantLock, OutPoint, Transaction}; +use dash_sdk::dpp::document::{Document, DocumentV0Getters}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; +use dash_sdk::dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::prelude::AssetLockProof; +use dash_sdk::dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; +use dash_sdk::platform::dpns_usernames::DpnsUsername; +use dash_sdk::query_types::Contenders; + +use crate::ffi; + +/// A quorum hash between the embedder's internal `uint256` byte order and +/// the order Platform uses: reversed. +pub fn reversed(hash: &[u8; 32]) -> [u8; 32] { + let mut out = *hash; + out.reverse(); + out +} + +pub fn identity(identity: &Identity) -> ffi::Identity { + ffi::Identity { + id: identity.id().to_buffer(), + balance: identity.balance(), + revision: identity.revision(), + keys: identity + .public_keys() + .values() + .map(|key| ffi::IdentityKey { + id: key.id(), + purpose: key.purpose() as u8, + security_level: key.security_level() as u8, + key_type: key.key_type() as u8, + read_only: key.read_only(), + data: key.data().to_vec(), + disabled_at: key.disabled_at().unwrap_or(0), + }) + .collect(), + } +} + +/// A key to register with a new identity; it must be enabled. +pub fn new_identity_key(key: &ffi::IdentityKey) -> Result { + if key.disabled_at != 0 { + return Err(format!("key {} is disabled", key.id)); + } + Ok(IdentityPublicKeyV0 { + id: key.id, + purpose: Purpose::try_from(key.purpose).map_err(|e| format!("key {}: {e}", key.id))?, + security_level: SecurityLevel::try_from(key.security_level) + .map_err(|e| format!("key {}: {e}", key.id))?, + contract_bounds: None, + key_type: KeyType::try_from(key.key_type).map_err(|e| format!("key {}: {e}", key.id))?, + read_only: key.read_only, + data: key.data.clone().into(), + disabled_at: None, + } + .into()) +} + +pub fn asset_lock_proof(proof: &ffi::AssetLockProof) -> Result { + match proof.kind { + ffi::AssetLockProofKind::Instant => { + let transaction = Transaction::consensus_decode(&mut proof.transaction.as_slice()) + .map_err(|e| format!("bad asset lock transaction: {e}"))?; + let instant_lock = InstantLock::consensus_decode(&mut proof.instant_lock.as_slice()) + .map_err(|e| format!("bad instant lock: {e}"))?; + Ok(AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + transaction, + proof.output_index, + ))) + } + ffi::AssetLockProofKind::Chain => Ok(AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: proof.core_chain_locked_height, + out_point: OutPoint::consensus_decode(&mut proof.out_point.as_slice()) + .map_err(|e| format!("bad asset lock outpoint: {e}"))?, + })), + other => Err(format!("unknown asset lock proof kind {}", other.repr)), + } +} + +pub fn dpns_name(name: DpnsUsername) -> ffi::DpnsName { + ffi::DpnsName { + label: name.label, + normalized_label: name.normalized_label, + owner: name.owner_id.to_buffer(), + identity: name + .records_identity_id + .map(|id| id.to_buffer()) + .unwrap_or_default(), + } +} + +pub fn vote_state(contenders: &Contenders) -> ffi::VoteState { + let (winner_kind, winner) = match contenders.winner.as_ref().map(|(info, _)| info) { + Some(ContestedDocumentVotePollWinnerInfo::WonByIdentity(id)) => { + (ffi::WinnerKind::Identity, id.to_buffer()) + } + Some(ContestedDocumentVotePollWinnerInfo::Locked) => (ffi::WinnerKind::Locked, [0; 32]), + _ => (ffi::WinnerKind::None, [0; 32]), + }; + ffi::VoteState { + contenders: contenders + .contenders + .iter() + .map(|(id, contender)| ffi::Contender { + identity: id.to_buffer(), + votes: contender.vote_tally().unwrap_or(0), + }) + .collect(), + abstain: contenders.abstain_vote_tally.unwrap_or(0), + lock: contenders.lock_vote_tally.unwrap_or(0), + winner_kind, + winner, + } +} + +pub fn empty_vote_state() -> ffi::VoteState { + ffi::VoteState { + contenders: Vec::new(), + abstain: 0, + lock: 0, + winner_kind: ffi::WinnerKind::None, + winner: [0; 32], + } +} + +pub fn contact_request(document: &Document) -> Result { + let properties: &BTreeMap = document.properties(); + let field = |name: &str| { + properties + .get(name) + .ok_or_else(|| format!("contactRequest {} has no {name}", document.id())) + }; + let index = |name: &str| { + field(name)? + .to_integer::() + .map_err(|e| format!("contactRequest {name}: {e}")) + }; + let optional_bytes = |name: &str| { + properties + .get(name) + .map(|value| value.to_binary_bytes()) + .transpose() + .map(Option::unwrap_or_default) + .map_err(|e| format!("contactRequest {name}: {e}")) + }; + Ok(ffi::ContactRequest { + document_id: document.id().to_buffer(), + owner: document.owner_id().to_buffer(), + to_user_id: field("toUserId")? + .to_identifier() + .map_err(|e| format!("contactRequest toUserId: {e}"))? + .to_buffer(), + encrypted_public_key: field("encryptedPublicKey")? + .to_binary_bytes() + .map_err(|e| format!("contactRequest encryptedPublicKey: {e}"))?, + sender_key_index: index("senderKeyIndex")?, + recipient_key_index: index("recipientKeyIndex")?, + account_reference: index("accountReference")?, + encrypted_account_label: optional_bytes("encryptedAccountLabel")?, + auto_accept_proof: optional_bytes("autoAcceptProof")?, + created_at_ms: document.created_at().unwrap_or(0), + created_at_core_height: document.created_at_core_block_height().unwrap_or(0), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::document::DocumentV0; + + #[test] + fn a_contact_request_document_is_marshalled_field_by_field() { + let mut properties = BTreeMap::from([ + ("toUserId".to_string(), Value::Identifier([2; 32])), + ("encryptedPublicKey".to_string(), Value::Bytes(vec![3; 96])), + ("senderKeyIndex".to_string(), Value::U32(4)), + ("recipientKeyIndex".to_string(), Value::U64(5)), + ("accountReference".to_string(), Value::U32(6)), + ("autoAcceptProof".to_string(), Value::Bytes(vec![7; 38])), + ]); + let document = |properties| -> Document { + DocumentV0 { + id: [1; 32].into(), + owner_id: [8; 32].into(), + properties, + created_at: Some(9), + created_at_core_block_height: Some(10), + ..Default::default() + } + .into() + }; + let request = contact_request(&document(properties.clone())).expect("marshals"); + assert_eq!( + (request.document_id, request.owner, request.to_user_id), + ([1; 32], [8; 32], [2; 32]) + ); + assert_eq!(request.encrypted_public_key, vec![3; 96]); + assert_eq!( + ( + request.sender_key_index, + request.recipient_key_index, + request.account_reference + ), + (4, 5, 6) + ); + assert!(request.encrypted_account_label.is_empty()); + assert_eq!(request.auto_accept_proof, vec![7; 38]); + assert_eq!( + (request.created_at_ms, request.created_at_core_height), + (9, 10) + ); + + properties.remove("toUserId"); + assert!(contact_request(&document(properties)).is_err()); + } +} diff --git a/packages/rs-sdk-cxx/src/runtime.rs b/packages/rs-sdk-cxx/src/runtime.rs new file mode 100644 index 00000000000..c5cb1591396 --- /dev/null +++ b/packages/rs-sdk-cxx/src/runtime.rs @@ -0,0 +1,241 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! The tokio runtime the binding owns. Every SDK call is spawned on its +//! workers while the calling thread blocks on the join handle, which `shutdown` +//! aborts: the SDK does not observe its cancellation token on every path. A +//! task's future, and the C++ callbacks it owns, is dropped before its join +//! handle resolves, and `shutdown` returns only once every [`Call`] has ended, +//! so no callback runs after it. + +use std::future::Future; +use std::sync::{Condvar, Mutex, MutexGuard, PoisonError}; +use std::time::Duration; + +use tokio::runtime::Handle; +use tokio::task::AbortHandle; + +/// Calls are async; two workers carry any number of them, plus the SDK's +/// own background work. Callbacks run through [`blocking`]. +const WORKER_THREADS: usize = 2; +/// GroveDB proof verification recurses deeper than a default thread stack. +const WORKER_STACK_SIZE: usize = 16 * 1024 * 1024; +/// How long `shutdown` waits for the SDK's own background tasks, which hold +/// no callbacks, once every call has ended. +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug)] +pub enum RunError { + /// The call was aborted by `shutdown`. + ShutDown, + /// The call panicked. + Panicked(String), +} + +#[derive(Default)] +struct Calls { + closing: bool, + active: usize, + tasks: Vec, +} + +pub struct Runtime { + /// Held for the whole of `shutdown`, so a second caller returns only once + /// the first has finished; `None` once stopped. + owned: Mutex>, + /// Kept for spawning and entering: `shutdown` stops the runtime only + /// after every call has ended. + pub handle: Handle, + calls: Mutex, + idle: Condvar, +} + +/// Every lock here guards plain data with no invariant spanning a write, so +/// a poisoned one is still served. +pub fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Runs a C++ callback, which may block on the embedder's locks, without +/// stalling the other tasks on its worker. +pub fn blocking(callback: impl FnOnce() -> T) -> T { + tokio::task::block_in_place(callback) +} + +/// One call in progress; `shutdown` waits until it is dropped. +pub struct Call<'a>(&'a Runtime); + +impl Drop for Call<'_> { + fn drop(&mut self) { + let mut calls = lock(&self.0.calls); + calls.active -= 1; + if calls.active == 0 { + self.0.idle.notify_all(); + } + } +} + +impl Call<'_> { + /// Runs `future` on the workers, blocking the calling thread until it + /// completes, is aborted by [`Runtime::shutdown`], or panics. + pub fn run(&self, future: F) -> Result + where + F: Future + Send + 'static, + T: Send + 'static, + { + let task = { + let mut calls = lock(&self.0.calls); + if calls.closing { + return Err(RunError::ShutDown); + } + let task = self.0.handle.spawn(future); + calls.tasks.retain(|task| !task.is_finished()); + calls.tasks.push(task.abort_handle()); + task + }; + match self.0.handle.block_on(task) { + Ok(value) => Ok(value), + Err(join) if join.is_cancelled() => Err(RunError::ShutDown), + Err(join) => Err(RunError::Panicked(join.to_string())), + } + } +} + +impl Runtime { + pub fn new() -> Result { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .thread_name("dash-sdk-cxx") + .thread_stack_size(WORKER_STACK_SIZE) + .enable_all() + .build() + .map_err(|e| format!("unable to start the SDK runtime: {e}"))?; + Ok(Runtime { + handle: runtime.handle().clone(), + owned: Mutex::new(Some(runtime)), + calls: Mutex::new(Calls::default()), + idle: Condvar::new(), + }) + } + + /// Starts a call, `None` once shutdown has begun. + pub fn call(&self) -> Option> { + let mut calls = lock(&self.calls); + if calls.closing { + return None; + } + calls.active += 1; + Some(Call(self)) + } + + /// Refuses new calls, aborts the running ones and waits for every call to + /// end: a call blocked in a C++ callback ends when the callback returns. + /// Then runs `before_stop` inside the runtime context and stops the + /// runtime. Idempotent. + pub fn shutdown(&self, before_stop: impl FnOnce()) { + let mut owned = lock(&self.owned); + let mut calls = lock(&self.calls); + calls.closing = true; + for task in calls.tasks.drain(..) { + task.abort(); + } + while calls.active > 0 { + calls = self + .idle + .wait(calls) + .unwrap_or_else(PoisonError::into_inner); + } + drop(calls); + if let Some(runtime) = owned.take() { + { + let _context = runtime.enter(); + before_stop(); + } + runtime.shutdown_timeout(SHUTDOWN_TIMEOUT); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{mpsc, Arc}; + + #[test] + fn a_panic_is_reported_and_shutdown_interrupts_a_call() { + let runtime = Arc::new(Runtime::new().expect("runtime")); + let call = runtime.call().expect("open"); + assert_eq!(call.run(async { 41 + 1 }).unwrap(), 42); + let error = call.run(async { panic!("hostile bytes") }).unwrap_err(); + assert!(matches!(error, RunError::Panicked(m) if m.contains("hostile bytes"))); + drop(call); + + let (running, running_rx) = mpsc::channel(); + let worker = { + let runtime = Arc::clone(&runtime); + std::thread::spawn(move || { + let call = runtime.call().expect("open"); + call.run(async move { + running.send(()).unwrap(); + tokio::time::sleep(Duration::from_secs(60)).await + }) + }) + }; + running_rx.recv().unwrap(); + let started = std::time::Instant::now(); + runtime.shutdown(|| ()); + assert!(matches!(worker.join().unwrap(), Err(RunError::ShutDown))); + assert!(started.elapsed() < Duration::from_secs(30)); + assert!(runtime.call().is_none()); + runtime.shutdown(|| ()); + } + + /// A call blocked in a callback keeps `shutdown` waiting until it ends. + #[test] + fn shutdown_waits_for_a_call_blocked_in_a_callback() { + let runtime = Arc::new(Runtime::new().expect("runtime")); + let (entered, entered_rx) = mpsc::channel(); + let (release, release_rx) = mpsc::channel::<()>(); + let released = Arc::new(AtomicBool::new(false)); + let worker = { + let (runtime, released) = (Arc::clone(&runtime), Arc::clone(&released)); + std::thread::spawn(move || { + let call = runtime.call().expect("open"); + let _ = call.run(async move { + blocking(|| { + entered.send(()).unwrap(); + release_rx.recv().unwrap(); + released.store(true, Ordering::SeqCst); + }) + }); + }) + }; + entered_rx.recv().unwrap(); + let (stopped, stopped_rx) = mpsc::channel(); + let stopper = { + let (runtime, released) = (Arc::clone(&runtime), Arc::clone(&released)); + std::thread::spawn(move || { + runtime.shutdown(|| ()); + assert!( + released.load(Ordering::SeqCst), + "shutdown returned before the call ended" + ); + stopped.send(()).unwrap(); + }) + }; + // Once new calls are refused, shutdown has begun; the callback + // still holds its call, so shutdown cannot have returned. + while runtime.call().is_some() { + std::thread::yield_now(); + } + assert!( + stopped_rx.try_recv().is_err(), + "shutdown returned before the call ended" + ); + release.send(()).unwrap(); + stopper.join().unwrap(); + worker.join().unwrap(); + } +} diff --git a/packages/rs-sdk-cxx/src/signer.rs b/packages/rs-sdk-cxx/src/signer.rs new file mode 100644 index 00000000000..6359acb1749 --- /dev/null +++ b/packages/rs-sdk-cxx/src/signer.rs @@ -0,0 +1,188 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +//! The SDK's two signer traits over the embedder's `WalletSigner`, in the +//! shape of `rs-sdk-ffi`'s `VTableSigner`: dpp's identity-key [`Signer`] +//! forwards the key and the signable bytes, and key-wallet's digest signer +//! forwards the asset lock's sighash. Neither sees a private key. + +use async_trait::async_trait; +use dash_sdk::dpp::address_funds::AddressWitness; +use dash_sdk::dpp::dashcore::ecdsa::{EcdsaPublicKey, EcdsaSignature}; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::signer::Signer; +use dash_sdk::dpp::identity::{IdentityPublicKey, KeyType}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::key_wallet::signer::{Signer as KeyWalletSigner, SignerMethod}; +use dash_sdk::dpp::platform_value::BinaryData; +use dash_sdk::dpp::secp256k1::ecdsa::{RecoverableSignature, RecoveryId}; +use dash_sdk::dpp::secp256k1::Message; +use dash_sdk::dpp::ProtocolError; + +use crate::ffi; +use crate::runtime::blocking; + +// SAFETY: `WalletSigner` and `ContactKeys` are documented as callable from +// any thread; their callbacks take the wallet's own locks. +unsafe impl Send for ffi::WalletSigner {} +unsafe impl Sync for ffi::WalletSigner {} +unsafe impl Send for ffi::ContactKeys {} +unsafe impl Sync for ffi::ContactKeys {} + +/// The embedder's `WalletSigner`. +pub struct WalletSigner(cxx::UniquePtr); + +impl WalletSigner { + pub fn new(signer: cxx::UniquePtr) -> Result { + if signer.is_null() { + return Err("a WalletSigner is required".to_string()); + } + Ok(WalletSigner(signer)) + } + + /// The asset-lock signer for the key the embedder knows as `key_ref`. + pub fn asset_lock(&self, key_ref: u32) -> AssetLockSigner<'_> { + AssetLockSigner { + signer: self, + key_ref, + } + } +} + +impl std::fmt::Debug for WalletSigner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("WalletSigner") + } +} + +#[async_trait] +impl Signer for WalletSigner { + async fn sign( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + if !self.can_sign_with(key) { + return Err(ProtocolError::Generic(format!( + "key {} is {:?}; the wallet signs ECDSA keys only", + key.id(), + key.key_type() + ))); + } + let mut signature = [0u8; 65]; + if !blocking(|| { + self.0.SignIdentityKey( + key.id(), + key.key_type() as u8, + key.data().as_slice(), + data, + &mut signature, + ) + }) { + return Err(ProtocolError::Generic(format!( + "the wallet refused to sign with key {}", + key.id() + ))); + } + Ok(BinaryData::new(signature.to_vec())) + } + + /// No entry point signs for Platform addresses. + async fn sign_create_witness( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Err(ProtocolError::Generic( + "the wallet does not sign address witnesses".to_string(), + )) + } + + fn can_sign_with(&self, key: &IdentityPublicKey) -> bool { + matches!( + key.key_type(), + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 + ) + } +} + +/// key-wallet's digest signer for an asset lock's credit-output key. The +/// derivation path is ignored: `key_ref` names the key to the embedder. +#[derive(Debug)] +pub struct AssetLockSigner<'a> { + signer: &'a WalletSigner, + key_ref: u32, +} + +#[async_trait] +impl KeyWalletSigner for AssetLockSigner<'_> { + type Error = String; + + fn supported_methods(&self) -> &[SignerMethod] { + &[SignerMethod::Digest] + } + + /// Recovers the public key from the compact signature, so the wallet + /// never exports it. + async fn sign_ecdsa( + &self, + _path: &DerivationPath, + sighash: [u8; 32], + ) -> Result<(EcdsaSignature, EcdsaPublicKey), String> { + let mut signature = [0u8; 65]; + if !blocking(|| { + self.signer + .0 + .SignAssetLock(self.key_ref, &sighash, &mut signature) + }) { + return Err("the wallet refused to sign the asset lock".to_string()); + } + let recoverable = compressed_key_signature(&signature)?; + let public_key = recoverable + .recover_ecdsa(Message::from_digest(sighash)) + .map_err(|e| format!("the asset lock signature recovers no public key: {e}"))?; + Ok((recoverable.to_standard().into(), public_key.into())) + } + + async fn public_key(&self, _path: &DerivationPath) -> Result { + Err("the wallet does not export the asset lock public key".to_string()) + } +} + +/// A compact signature by a compressed key: header 31 + recovery id. +fn compressed_key_signature(signature: &[u8; 65]) -> Result { + let recovery_id = RecoveryId::try_from(i32::from(signature[0]) - 31).map_err(|_| { + format!( + "signature header {:#04x} is not a compressed-key header (31..=34)", + signature[0] + ) + })?; + RecoverableSignature::from_compact(&signature[1..], recovery_id) + .map_err(|e| format!("malformed asset lock signature: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey}; + + #[test] + fn an_asset_lock_signature_recovers_its_compressed_key() { + let key = SecretKey::from_secret_bytes([1; 32]).expect("key"); + let message = Message::from_digest([9; 32]); + let (recovery_id, compact) = + RecoverableSignature::sign_ecdsa_recoverable(message, &key).serialize_compact(); + let mut signature = [0u8; 65]; + signature[0] = 31 + u8::from(recovery_id); + signature[1..].copy_from_slice(&compact); + + let recovered = compressed_key_signature(&signature) + .and_then(|sig| sig.recover_ecdsa(message).map_err(|e| e.to_string())) + .expect("recovers"); + assert_eq!(recovered, PublicKey::from_secret_key(&key)); + // An uncompressed-key header is refused. + signature[0] -= 4; + assert!(compressed_key_signature(&signature).is_err()); + } +} diff --git a/packages/rs-sdk-cxx/tests/cxx_smoke.cc b/packages/rs-sdk-cxx/tests/cxx_smoke.cc new file mode 100644 index 00000000000..859b943fba2 --- /dev/null +++ b/packages/rs-sdk-cxx/tests/cxx_smoke.cc @@ -0,0 +1,312 @@ +// 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. + +// Links the staged headers and the static archive the way an embedder does +// and crosses every bridged entry point once against a closed port: each +// call must come back as a typed status, never an exception or an abort. +// Each callback method is called directly for success, refusal and +// exception containment, and shutdown must destroy the CoreContext before +// any concurrent caller returns. + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace dash_sdk; + +namespace { + +int failures = 0; + +void check(bool ok, const char* what) +{ + if (!ok) { + std::fprintf(stderr, "cxx_smoke: %s\n", what); + ++failures; + } +} + +bool unproved(const Status& status) +{ + return status.kind != StatusKind::Ok && status.kind != StatusKind::NotFound; +} + +//! Flags its destruction, so a test can see when the CoreContext holding it +//! is destroyed. +struct Sentinel { + explicit Sentinel(std::atomic& flag) : destroyed(flag) {} + ~Sentinel() { destroyed = true; } + std::atomic& destroyed; +}; + +std::unique_ptr context(std::shared_ptr sentinel = nullptr) +{ + return std::make_unique( + [sentinel](uint8_t, const std::array&, uint32_t, std::vector&) { return false; }, + [](uint32_t&) { return false; }); +} + +Config config() +{ + Config cfg{}; + cfg.network = Network::Testnet; + cfg.platform_llmq_type = 106; + cfg.proxy.kind = ProxyKind::None; + cfg.endpoints.push_back("https://127.0.0.1:1"); + return cfg; +} + +bool refused(Config cfg, std::unique_ptr ctx = context()) +{ + try { + new_platform_client(cfg, std::move(ctx)); + } catch (const rust::Error&) { + return true; + } + return false; +} + +} // namespace + +int main() +{ + static_assert(sizeof(Network) == 1 && sizeof(StatusKind) == 1); + static_assert(static_cast(StatusKind::NotFound) == 1); + static_assert(static_cast(StatusKind::Consensus) == 4); + static_assert(static_cast(AssetLockProofKind::Chain) == 1); + + // Configs the client cannot honour are errors, never a direct fallback. + { + Config bad = config(); + bad.network = static_cast(42); + check(refused(bad), "unknown network accepted"); + bad = config(); + bad.proxy.kind = ProxyKind::Tcp; + bad.proxy.address = "localhost:9050"; + check(refused(bad), "proxy host name accepted"); + bad.proxy.address = "127.0.0.1:9050"; + bad.proxy.kind = ProxyKind::None; + check(refused(bad), "proxy address without a proxy kind accepted"); + bad.proxy.kind = static_cast(7); + check(refused(bad), "unknown proxy kind accepted"); + bad.proxy.kind = ProxyKind::Unix; + bad.proxy.address = ""; + check(refused(bad), "Unix proxy without a path accepted"); + bad.proxy.address = "/run/tor/socks"; + check(!refused(bad), "Unix proxy with a path refused"); + bad.proxy.kind = ProxyKind::Tcp; + bad.proxy.address = "127.0.0.1:9050"; + bad.endpoints.push_back("https://evo.example:443"); + check(refused(bad), "proxied endpoint host name accepted"); + const char* onion = "https://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion:443"; + for (const char* uri : {"https://evo.example:443", "http://1.1.1.1:443", "1.1.1.1:443", onion}) { + bad = config(); + bad.endpoints.push_back(uri); + check(refused(bad), "endpoint accepted: not https with an IP host, or an onion host without a proxy"); + } + check(refused(config(), nullptr), "missing CoreContext accepted"); + bad = config(); + bad.proxy.kind = ProxyKind::Tcp; + bad.proxy.address = "[::1]:9050"; + bad.proxy.isolate = true; + bad.endpoints.push_back(onion); + bad.endpoints.push_back("https://[2001:db8::1]:443"); + check(!refused(bad), "proxied client with onion and IPv6 endpoints refused"); + } + + rust::Box client = new_platform_client(config(), context()); + const std::array id{1}; + const std::array hash160{2}; + + // Reads. + check(unproved(client->get_identity(id).status), "get_identity"); + check(unproved(client->get_identity_by_pubkey_hash(hash160).status), "get_identity_by_pubkey_hash"); + check(unproved(client->resolve_name("alice").status), "resolve_name"); + check(unproved(client->names_of_identity(id, 100, 10).status), "names_of_identity"); + check(client->names_of_identity(id, 0, 10).status.kind == StatusKind::Internal, "page_limit 0 accepted"); + check(client->names_of_identity(id, 100, 0).status.kind == StatusKind::Internal, "max_pages 0 accepted"); + check(unproved(client->search_names("ali", 10).status), "search_names"); + check(client->search_names("ali", 0).status.kind == StatusKind::Internal, "search limit 0 accepted"); + check(unproved(client->pending_contests_for_identity(id).status), "pending_contests_for_identity"); + check(unproved(client->contested_vote_state("a11ce").status), "contested_vote_state"); + check(unproved(client->contact_requests(id, ContactDirection::Received, 0).status), "contact_requests"); + check(client->contact_requests(id, static_cast(9), 0).status.kind == StatusKind::Internal, + "unknown contact direction accepted"); + + // Writes. Identity registration and top-up sign before they send, so + // the WalletSigner is reached with the key and the asset-lock key_ref. + int identity_key_calls = 0; + int asset_lock_calls = 0; + const auto signer = [&](bool throw_on_asset_lock) { + return std::make_unique( + [&](uint32_t key_id, uint8_t key_type, std::span key_data, std::span signable, + std::array& sig) { + ++identity_key_calls; + check(key_id == 0 && key_type == 0 && key_data.size() == 33 && !signable.empty(), + "identity key marshalled wrongly"); + sig.fill(0x01); + return true; + }, + [&, throw_on_asset_lock](uint32_t key_ref, const std::array&, std::array&) -> bool { + ++asset_lock_calls; + check(key_ref == 77, "asset lock key_ref not passed back"); + if (throw_on_asset_lock) throw std::runtime_error("wallet failure"); + return false; + }); + }; + IdentityKey key{}; + key.id = 0; + key.purpose = 0; // AUTHENTICATION + key.security_level = 0; // MASTER + key.key_type = 0; // ECDSA_SECP256K1 + key.data.push_back(0x02); + for (int i = 0; i < 32; ++i) key.data.push_back(0x79); + AssetLockProof proof{}; + proof.kind = AssetLockProofKind::Chain; + proof.core_chain_locked_height = 1; + const std::vector keys{key}; + const rust::Slice key_slice(keys.data(), keys.size()); + + const auto created = client->register_identity(key_slice, proof, 77, signer(true)); + check(created.status.kind == StatusKind::Internal, "a throwing asset-lock signer was not a refusal"); + const auto topped_up = client->top_up_identity(id, proof, 77, signer(false)); + check(topped_up.status.kind == StatusKind::Internal, "a refusing asset-lock signer was not a refusal"); + check(asset_lock_calls == 2, "the asset-lock signer was not reached"); + check(identity_key_calls <= 1, "the identity key was signed more than once"); + proof.kind = static_cast(5); + check(client->register_identity(key_slice, proof, 77, signer(false)).status.kind == StatusKind::Internal, + "unknown proof kind accepted"); + check(client->register_identity(key_slice, proof, 77, nullptr).status.kind == StatusKind::Internal, + "missing signer accepted"); + + check(unproved(client->register_dpns_name("alice", id, 1, 0, signer(false)).status), "register_dpns_name"); + ContactRequestInput request{}; + request.sender = id; + request.recipient = id; + check(client->send_contact_request(request, signer(false), nullptr).status.kind == StatusKind::Internal, + "missing ContactKeys accepted"); + auto contact_keys = std::make_unique( + [](uint32_t, const std::array&, std::array&) -> bool { throw std::runtime_error("x"); }, + nullptr); + check(unproved(client->send_contact_request(request, signer(false), std::move(contact_keys)).status), + "send_contact_request"); + + // No network. + check(is_valid_username("alice") && !is_valid_username("-"), "is_valid_username"); + check(is_contested_username("alice") && !is_contested_username("alice12345678901234567890"), + "is_contested_username"); + bool threw = false; + try { + const std::array secret{}; + const std::vector blob(96, 0); + decrypt_contact_xpub(secret, rust::Slice(blob.data(), blob.size())); + } catch (const rust::Error&) { + threw = true; + } + check(threw, "an undecryptable xpub was accepted"); + + // The callback types contain exceptions and check sizes. + { + const std::array hash{}; + std::array out{}; + const auto answer = [](size_t size) { + return CoreContext( + [size](uint8_t, const std::array&, uint32_t, std::vector& key) { + key.assign(size, 0x05); + return true; + }, + [](uint32_t&) -> bool { throw std::runtime_error("x"); }); + }; + check(!answer(47).QuorumPublicKey(106, hash, 1, out), "a 47-byte quorum key accepted"); + check(!answer(49).QuorumPublicKey(106, hash, 1, out), "a 49-byte quorum key accepted"); + check(out == std::array{}, "a refused quorum key was copied"); + check(answer(48).QuorumPublicKey(106, hash, 1, out) && out[47] == 0x05, "a 48-byte quorum key refused"); + uint32_t height = 0; + check(!answer(48).PlatformActivationHeight(height), "a throwing callback was not a refusal"); + const CoreContext throwing( + [](uint8_t, const std::array&, uint32_t, std::vector&) -> bool { + throw std::runtime_error("x"); + }, + nullptr); + check(!throwing.QuorumPublicKey(106, hash, 1, out) && !throwing.PlatformActivationHeight(height), + "a throwing or empty CoreContext was not a refusal"); + std::array sig{}; + const WalletSigner empty(nullptr, nullptr); + check(!empty.SignAssetLock(1, hash, sig), "an empty WalletSigner signed"); + + // WalletSigner::SignIdentityKey and ContactKeys: success, refusal, and + // a throw contained as a refusal. + const std::array data{1, 2, 3}; + const rust::Slice bytes(data.data(), data.size()); + const auto signs = [](int mode) { + return WalletSigner( + [mode](uint32_t, uint8_t, std::span key, std::span signable, + std::array& out) -> bool { + if (mode == 2) throw std::runtime_error("x"); + out.fill(static_cast(key.size() + signable.size())); + return mode == 0; + }, + nullptr); + }; + check(signs(0).SignIdentityKey(1, 0, bytes, bytes, sig) && sig[64] == 6, "SignIdentityKey did not answer"); + check(!signs(1).SignIdentityKey(1, 0, bytes, bytes, sig), "a refused SignIdentityKey signed"); + check(!signs(2).SignIdentityKey(1, 0, bytes, bytes, sig), "a throwing SignIdentityKey signed"); + const auto keys_for = [](int mode) { + return ContactKeys( + [mode](uint32_t, const std::array& theirs, std::array& out) -> bool { + if (mode == 2) throw std::runtime_error("x"); + out.fill(theirs[0]); + return mode == 0; + }, + [mode](const std::array& recipient, uint32_t reference, std::array& out) -> bool { + if (mode == 2) throw std::runtime_error("x"); + out.fill(static_cast(recipient[0] + reference)); + return mode == 0; + }); + }; + const std::array theirs{9}; + std::array secret{}; + std::array xpub{}; + check(keys_for(0).SharedSecret(1, theirs, secret) && secret[31] == 9, "SharedSecret did not answer"); + check(keys_for(0).CompactXpub(id, 2, xpub) && xpub[68] == 3, "CompactXpub did not answer"); + for (int mode : {1, 2}) { + check(!keys_for(mode).SharedSecret(1, theirs, secret), "a refused or throwing SharedSecret answered"); + check(!keys_for(mode).CompactXpub(id, 2, xpub), "a refused or throwing CompactXpub answered"); + } + } + + client->shutdown(); + client->shutdown(); + // One representative call after shutdown. + check(client->get_identity(id).status.kind == StatusKind::Unavailable, "a call after shutdown is not Unavailable"); + + // Concurrent shutdowns: neither returns before the CoreContext is gone. + // Timing-dependent; the guarantee itself is structural (client.rs). + { + std::atomic destroyed{false}; + auto sentinel = std::make_shared(destroyed); + rust::Box other = new_platform_client(config(), context(sentinel)); + sentinel.reset(); + std::atomic early{0}; + const auto stop = [&] { + other->shutdown(); + if (!destroyed) ++early; + }; + std::thread a(stop), b(stop); + a.join(); + b.join(); + check(destroyed && early == 0, "a shutdown returned before the CoreContext was destroyed"); + } + + if (failures == 0) std::puts("cxx_smoke: ok"); + return failures == 0 ? 0 : 1; +} From e4c2f9474f27608d3e38022e175a1b68bde20f67 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 01:09:08 -0500 Subject: [PATCH 2/5] ci(sdk): test dash-sdk-cxx and guard its trust closure Add the crate to the Rust package filters and the workspace nextest run, and two steps to the workspace job: fail when a trusted HTTP quorum provider, an HTTP client or OpenSSL enters the crate's normal dependency closure, or when its sources reach for built-in network seeds; and link and run the C++ smoke test from the staged headers and static archive. Co-Authored-By: Claude Opus 5.5 --- .../package-filters/rs-packages-direct.yml | 3 +++ .../rs-packages-no-workflows.yml | 4 +++ .github/package-filters/rs-packages.yml | 5 ++++ .github/workflows/tests-rs-workspace.yml | 25 +++++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/.github/package-filters/rs-packages-direct.yml b/.github/package-filters/rs-packages-direct.yml index 25201a06fe1..f7d34c79f04 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -134,6 +134,9 @@ dash-platform-queries: dash-sdk: - packages/rs-sdk/** +dash-sdk-cxx: + - packages/rs-sdk-cxx/** + rs-sdk-ffi: - packages/rs-sdk-ffi/** diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index 934d478c05a..0e8fd5be837 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -156,6 +156,10 @@ dash-sdk: &sdk - *dapi_client - *drive +dash-sdk-cxx: + - packages/rs-sdk-cxx/** + - *sdk + rs-sdk-ffi: &sdk_ffi - packages/rs-sdk-ffi/** - *simple-signer diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index 854e8c8bfdc..65c2ac55e31 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -185,6 +185,11 @@ dash-sdk: &sdk - *dapi_client - *drive +dash-sdk-cxx: + - .github/workflows/tests* + - packages/rs-sdk-cxx/** + - *sdk + rs-sdk-ffi: &sdk_ffi - .github/workflows/tests* - packages/rs-sdk-ffi/** diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index a10c602353f..95e7090a699 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -270,6 +270,30 @@ jobs: done done + # Dash Core links dash-sdk-cxx as a static archive whose trust anchors + # are its own quorum keys and masternode list: no trusted HTTP + # provider, HTTP client or OpenSSL may enter its closure, and it builds + # its SDK from the embedder's endpoints only. + - name: Check the dash-sdk-cxx trust closure + run: | + closure=$(cargo tree -p dash-sdk-cxx -e normal --prefix none --locked | awk '{print $1}' | sort -u) + for banned in rs-sdk-trusted-context-provider reqwest openssl-sys native-tls; do + if grep -qx "$banned" <<<"$closure"; then + echo "::error::$banned leaked into dash-sdk-cxx's dependency tree" + exit 1 + fi + done + if grep -rnE 'new_mainnet|new_testnet|fetch_unproved|dash_network_seeds' \ + packages/rs-sdk-cxx/src packages/rs-sdk-cxx/include; then + echo "::error::dash-sdk-cxx must build its SDK from the embedder's endpoints only" + exit 1 + fi + + # The Rust tests cannot see a broken header layout: link and run the + # C++ side from the staged headers and archive alone. + - name: Link dash-sdk-cxx from C++ + run: packages/rs-sdk-cxx/scripts/cxx-smoke.sh + - name: Detect immutable structure changes if: github.event_name == 'pull_request' run: | @@ -465,6 +489,7 @@ jobs: --package platform-wallet \ --package platform-wallet-storage \ --package rs-sdk-ffi \ + --package dash-sdk-cxx \ --package platform-wallet-ffi \ --package rs-dapi-client \ --package platform-serialization \ From 8b4f58e92734e082cfccab3e14dabff720354717 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 15:31:54 +0000 Subject: [PATCH 3/5] fix(sdk): read every contender of a contested DPNS vote state get_contested_dpns_vote_state sent one query, and Drive answers at most 100 contenders to one, so a contest with more came back truncated with no sign of it. It now reads pages of up to 100 after the last contender read, as get_all_contested_dpns_contenders does: None reads every contender, Some(n) up to n. The tallies and the winner come from the first page, the only one Drive returns them to. get_non_resolved_dpns_contests_for_identity leaves out a contest whose vote state cannot be read, so a timeout or a rejected proof reads as "not contending". The new get_non_resolved_dpns_contests_for_identity_strict fails instead, and dash-sdk-cxx's pending_contests_for_identity uses it. The best-effort calls are unchanged for platform-wallet and rs-sdk-ffi. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk-cxx/src/client.rs | 2 +- packages/rs-sdk-cxx/src/lib.rs | 4 +- .../dpns_usernames/contested_queries.rs | 287 ++++++++++++++++-- 3 files changed, 272 insertions(+), 21 deletions(-) diff --git a/packages/rs-sdk-cxx/src/client.rs b/packages/rs-sdk-cxx/src/client.rs index ac7792dba4a..0e1534f5d30 100644 --- a/packages/rs-sdk-cxx/src/client.rs +++ b/packages/rs-sdk-cxx/src/client.rs @@ -323,7 +323,7 @@ impl PlatformClient { let identity = Identifier::from(*identity); async move { let contests = sdk - .get_non_resolved_dpns_contests_for_identity(identity, None) + .get_non_resolved_dpns_contests_for_identity_strict(identity, None) .await?; Ok(contests .into_iter() diff --git a/packages/rs-sdk-cxx/src/lib.rs b/packages/rs-sdk-cxx/src/lib.rs index 69529d9f184..617cca47344 100644 --- a/packages/rs-sdk-cxx/src/lib.rs +++ b/packages/rs-sdk-cxx/src/lib.rs @@ -348,8 +348,8 @@ pub mod ffi { /// `prefix`. `complete` is always false: the SDK does not report /// whether it reached the end. fn search_names(self: &PlatformClient, prefix: &str, limit: u32) -> NamesResult; - /// Unresolved contests `identity` takes part in; best effort, the - /// SDK examines at most 100 contests. + /// Unresolved contests `identity` takes part in. A contest whose + /// vote state cannot be read fails the call. fn pending_contests_for_identity( self: &PlatformClient, identity: &[u8; 32], diff --git a/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs b/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs index 5476ad62314..5de0a064e85 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs @@ -120,10 +120,16 @@ impl Sdk { /// Get the vote state for a contested DPNS username /// + /// Reads the contenders in identity order, in pages of up to 100 (the most Drive answers to + /// one query) that each start after the last contender read, until `limit` contenders are + /// read or none are left. The abstain and lock tallies and the winner come from the first + /// page: Drive returns the tallies only to a query without a start. + /// /// # Arguments /// /// * `label` - The username label to check (e.g., "alice") - /// * `limit` - Maximum number of contenders to return + /// * `limit` - Maximum number of contenders to return; `None` returns every contender, and + /// `Some(0)` returns an empty state without a request /// /// # Returns /// @@ -145,20 +151,50 @@ impl Sdk { ], }; - let query = ContestedDocumentVotePollDriveQuery { - vote_poll, - result_type: ContestedDocumentVotePollDriveQueryResultType::DocumentsAndVoteTally, - allow_include_locked_and_abstaining_vote_tally: true, - start_at: None, - limit: limit.map(|l| l as u16), - offset: None, - }; + let mut state: Option = None; + let mut start_at = None; + loop { + let read = state.as_ref().map_or(0, |state| state.contenders.len()); + let page_limit = match limit { + Some(limit) => { + let left = (limit as usize).saturating_sub(read); + if left == 0 { + break; + } + left.min(DEFAULT_QUERY_LIMIT as usize) as u16 + } + None => DEFAULT_QUERY_LIMIT, + }; + let page = ContenderWithSerializedDocument::fetch_many( + self, + ContestedDocumentVotePollDriveQuery { + vote_poll: vote_poll.clone(), + result_type: + ContestedDocumentVotePollDriveQueryResultType::DocumentsAndVoteTally, + allow_include_locked_and_abstaining_vote_tally: true, + start_at, + limit: Some(page_limit), + offset: None, + }, + ) + .await?; + let page_len = page.contenders.len(); + let last = page.contenders.keys().last().copied(); + match state.as_mut() { + None => state = Some(page), + Some(state) => state.contenders.extend(page.contenders), + } - // Fetch the contenders using FetchMany - // ContenderWithSerializedDocument implements FetchMany and returns Contenders - let result = ContenderWithSerializedDocument::fetch_many(self, query).await?; + // A short page is the last one + match last { + Some(last) if page_len >= page_limit as usize => { + start_at = Some((last.to_buffer(), false)); + } + _ => break, + } + } - Ok(result) + Ok(state.unwrap_or_default()) } /// Get voters who voted for a specific identity for a contested DPNS username @@ -368,6 +404,16 @@ impl Sdk { pub async fn get_contested_non_resolved_usernames( &self, limit: Option, + ) -> Result, Error> { + self.non_resolved_dpns_contests(limit, true).await + } + + /// `skip_failed`: leave out a name whose vote state cannot be read, + /// rather than fail. + async fn non_resolved_dpns_contests( + &self, + limit: Option, + skip_failed: bool, ) -> Result, Error> { // First, get all current DPNS contests (returns BTreeMap) let current_contests = self @@ -400,10 +446,8 @@ impl Sdk { ); } } - Err(_) => { - // If we can't get the vote state, skip this name - // (we could include it with empty contenders, but it's better to skip) - } + Err(_) if skip_failed => {} + Err(error) => return Err(error), } // Check if we've reached the limit @@ -434,9 +478,30 @@ impl Sdk { &self, identity_id: Identifier, limit: Option, + ) -> Result, Error> { + self.non_resolved_dpns_contests_for_identity(identity_id, limit, true) + .await + } + + /// [`Self::get_non_resolved_dpns_contests_for_identity`], failing when a + /// contest's vote state cannot be read instead of leaving the contest out. + pub async fn get_non_resolved_dpns_contests_for_identity_strict( + &self, + identity_id: Identifier, + limit: Option, + ) -> Result, Error> { + self.non_resolved_dpns_contests_for_identity(identity_id, limit, false) + .await + } + + async fn non_resolved_dpns_contests_for_identity( + &self, + identity_id: Identifier, + limit: Option, + skip_failed: bool, ) -> Result, Error> { // First, get all non-resolved contests - let all_non_resolved = self.get_contested_non_resolved_usernames(limit).await?; + let all_non_resolved = self.non_resolved_dpns_contests(limit, skip_failed).await?; // Filter to only include contests where the identity is a contender let mut identity_contests: BTreeMap = BTreeMap::new(); @@ -908,4 +973,190 @@ mod tests { assert_eq!(usernames[0].label, name(0)); assert_eq!(usernames[0].contenders, contenders(0, 160)); } + + /// The vote state page of the contest for `label` after `start_after`; only a page without + /// a start carries the abstain and lock tallies + async fn expect_vote_state_page( + sdk: &mut Sdk, + label: &str, + start_after: Option, + limit: u16, + page: &[Identifier], + ) { + let query = ContestedDocumentVotePollDriveQuery { + vote_poll: ContestedDocumentResourceVotePoll { + contract_id: sdk + .get_dpns_contract_id() + .expect("expected the dpns contract id"), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text(label.to_string()), + ], + }, + result_type: ContestedDocumentVotePollDriveQueryResultType::DocumentsAndVoteTally, + allow_include_locked_and_abstaining_vote_tally: true, + start_at: start_after.map(|id| (id.to_buffer(), false)), + limit: Some(limit), + offset: None, + }; + let first = start_after.is_none(); + let page = Contenders { + winner: None, + contenders: page + .iter() + .map(|&identity_id| { + ( + identity_id, + ContenderWithSerializedDocument::V0(ContenderWithSerializedDocumentV0 { + identity_id, + serialized_document: None, + vote_tally: Some(1), + }), + ) + }) + .collect(), + abstain_vote_tally: first.then_some(7), + lock_vote_tally: first.then_some(8), + }; + sdk.mock() + .expect_fetch_many::<_, ContenderWithSerializedDocument, _, Contenders>( + query, + Some(page), + ) + .await + .expect("expected to register the vote state page"); + } + + /// A contest holds up to 1,000 contenders and Drive answers 100 per query: every contender + /// is read, and the tallies of the first page are kept + #[tokio::test] + async fn should_read_every_contender_of_a_vote_state_across_pages() { + let mut sdk = mock_sdk(); + expect_vote_state_page(&mut sdk, "alice", None, 100, &contenders(0, 100)).await; + expect_vote_state_page( + &mut sdk, + "alice", + Some(contender(99)), + 100, + &contenders(100, 160), + ) + .await; + + let state = sdk + .get_contested_dpns_vote_state("alice", None) + .await + .expect("expected both pages to answer"); + + assert_eq!( + state.contenders.keys().copied().collect::>(), + contenders(0, 160) + ); + assert_eq!( + (state.abstain_vote_tally, state.lock_vote_tally), + (Some(7), Some(8)) + ); + } + + #[tokio::test] + async fn should_read_a_vote_state_limit_over_100_in_pages_of_at_most_100() { + let mut sdk = mock_sdk(); + expect_vote_state_page(&mut sdk, "alice", None, 100, &contenders(0, 100)).await; + expect_vote_state_page( + &mut sdk, + "alice", + Some(contender(99)), + 20, + &contenders(100, 120), + ) + .await; + + let state = sdk + .get_contested_dpns_vote_state("alice", Some(120)) + .await + .expect("expected both pages to answer"); + + assert_eq!(state.contenders.len(), 120); + assert_eq!( + (state.abstain_vote_tally, state.lock_vote_tally), + (Some(7), Some(8)) + ); + } + + #[tokio::test] + async fn should_read_a_vote_state_limit_of_at_most_100_in_one_page() { + let mut sdk = mock_sdk(); + expect_vote_state_page(&mut sdk, "alice", None, 10, &contenders(0, 10)).await; + + let state = sdk + .get_contested_dpns_vote_state("alice", Some(10)) + .await + .expect("expected the page to answer"); + + assert_eq!(state.contenders.len(), 10); + } + + /// One page of vote polls by end date, all of them the DPNS contests for `labels` + async fn expect_end_date_page( + sdk: &mut Sdk, + start_time: Option<(TimestampMillis, bool)>, + end_date: TimestampMillis, + labels: &[&str], + ) { + let contract_id = sdk + .get_dpns_contract_id() + .expect("expected the dpns contract id"); + let polls = labels + .iter() + .map(|label| { + VotePoll::ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll { + contract_id, + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text(label.to_string()), + ], + }) + }) + .collect::>(); + let page = VotePollsGroupedByTimestamp(if polls.is_empty() { + vec![] + } else { + vec![(end_date, polls)] + }); + let query = VotePollsByEndDateDriveQuery { + start_time, + end_time: None, + limit: Some(100), + offset: None, + order_ascending: true, + }; + sdk.mock() + .expect_fetch_many::<_, VotePoll, _, VotePollsGroupedByTimestamp>(query, Some(page)) + .await + .expect("expected to register the page of vote polls"); + } + + /// The contest is listed but its vote state cannot be read (no expectation answers it): the + /// strict read fails, where the best-effort one leaves the contest out + #[tokio::test] + async fn should_fail_a_strict_contest_read_whose_vote_state_fails() { + let identity = contender(1); + let mut sdk = mock_sdk(); + expect_end_date_page(&mut sdk, None, 50, &["alice"]).await; + expect_end_date_page(&mut sdk, Some((50, true)), 50, &["alice"]).await; + expect_end_date_page(&mut sdk, Some((50, false)), 50, &[]).await; + + assert!(sdk + .get_non_resolved_dpns_contests_for_identity_strict(identity, None) + .await + .is_err()); + let contests = sdk + .get_non_resolved_dpns_contests_for_identity(identity, None) + .await + .expect("expected the best-effort read to answer"); + assert!(contests.is_empty()); + } } From 9a1ea9571627fc170135f823fad73d4c812cb2dc Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 15:31:54 +0000 Subject: [PATCH 4/5] fix(sdk): stop a resumable contact-request sweep before a lossy page boundary The SDK sweep pages on a document-id cursor. Before protocol version 14 a continuation skips the rest of the boundary document's $createdAt, and a sweep stopped at its page budget leaves the rest of its last $createdAt unread. Dash Core resumes after the last $createdAt it read, so either way requests were lost without a trace. fetch_{sent,received}_contact_requests_resumable return only the requests before that $createdAt, with complete false so the caller reads on, and nothing when one $createdAt fills the page. Before PV14 only the first page can be kept, so they read no other. The plain sweep is left as it is: platform-wallet calls it with an overlap rewind. dash-sdk-cxx's contact_requests marshals that sweep, with the new ContactRequestsResult::complete, and Unavailable when the sweep cannot progress. The contactRequest schema bounds the key indices and accountReference below only, so a valid document can hold one above u32, which used to fail the whole sweep. ContactRequest now carries them as u64, as stored; DIP-15's u32 range is the caller's to check. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk-cxx/src/client.rs | 47 ++- packages/rs-sdk-cxx/src/lib.rs | 22 +- packages/rs-sdk-cxx/src/marshal.rs | 29 +- packages/rs-sdk-cxx/tests/cxx_smoke.cc | 3 +- .../dashpay/contact_request_queries.rs | 363 +++++++++++++++++- packages/rs-sdk/src/platform/dashpay/mod.rs | 2 +- 6 files changed, 428 insertions(+), 38 deletions(-) diff --git a/packages/rs-sdk-cxx/src/client.rs b/packages/rs-sdk-cxx/src/client.rs index 0e1534f5d30..2d37a2cf3dd 100644 --- a/packages/rs-sdk-cxx/src/client.rs +++ b/packages/rs-sdk-cxx/src/client.rs @@ -146,7 +146,20 @@ result_from!(VoteStateResult { state: ffi::VoteState = marshal::empty_vote_state() }); result_from!(ContestsResult { contests: Vec = Vec::new() }); -result_from!(ContactRequestsResult { requests: Vec = Vec::new() }); + +impl From, bool), Status>> for ffi::ContactRequestsResult { + fn from(outcome: Result<(Vec, bool), Status>) -> Self { + let (status, (requests, complete)) = match outcome { + Ok(value) => (Status::ok(), value), + Err(status) => (status, (Vec::new(), false)), + }; + ffi::ContactRequestsResult { + status, + requests, + complete, + } + } +} impl From, bool), Status>> for ffi::NamesResult { fn from(outcome: Result<(Vec, bool), Status>) -> Self { @@ -360,12 +373,14 @@ impl PlatformClient { let identity = Identifier::from(*identity); let since = (since_ms != 0).then_some(since_ms); async move { - let documents = match direction { + let sweep = match direction { ffi::ContactDirection::Sent => { - sdk.fetch_sent_contact_requests(identity, since).await + sdk.fetch_sent_contact_requests_resumable(identity, since) + .await } ffi::ContactDirection::Received => { - sdk.fetch_received_contact_requests(identity, since).await + sdk.fetch_received_contact_requests_resumable(identity, since) + .await } other => { return Err(Status::internal(format!( @@ -374,12 +389,26 @@ impl PlatformClient { ))) } }?; - documents - .values() - .flatten() - .map(marshal::contact_request) + if sweep.documents.is_empty() && !sweep.complete { + return Err(Status::new( + StatusKind::Unavailable, + "too many contact requests share one created_at to read them all", + )); + } + // Every request counts toward the resume point, so none may + // be skipped. + let requests = sweep + .documents + .iter() + .map(|(id, document)| { + document + .as_ref() + .ok_or_else(|| format!("contactRequest {id} has no document")) + .and_then(marshal::contact_request) + }) .collect::>() - .map_err(|e| Status::new(StatusKind::Rejected, e)) + .map_err(|e| Status::new(StatusKind::Rejected, e))?; + Ok((requests, sweep.complete)) } }) .into() diff --git a/packages/rs-sdk-cxx/src/lib.rs b/packages/rs-sdk-cxx/src/lib.rs index 617cca47344..394b71f933c 100644 --- a/packages/rs-sdk-cxx/src/lib.rs +++ b/packages/rs-sdk-cxx/src/lib.rs @@ -199,26 +199,31 @@ pub mod ffi { } /// A DashPay `contactRequest` document. Empty vectors mean the field is - /// absent. + /// absent. The schema bounds the indices below only, so they come as + /// stored: one above u32 is no DIP-15 request, for the caller to refuse. #[derive(Debug, Clone, Default)] struct ContactRequest { document_id: [u8; 32], owner: [u8; 32], to_user_id: [u8; 32], encrypted_public_key: Vec, - sender_key_index: u32, - recipient_key_index: u32, - account_reference: u32, + sender_key_index: u64, + recipient_key_index: u64, + account_reference: u64, encrypted_account_label: Vec, auto_accept_proof: Vec, created_at_ms: u64, created_at_core_height: u32, } + /// `requests`: oldest first. `complete`: false when more may follow; + /// they are read by calling again with `since_ms` set to the last + /// request's `created_at_ms`. #[derive(Debug, Clone)] struct ContactRequestsResult { status: Status, requests: Vec, + complete: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -354,10 +359,13 @@ pub mod ffi { self: &PlatformClient, identity: &[u8; 32], ) -> ContestsResult; - /// The contest for a DPNS label (normalized here). + /// The contest for a DPNS label (normalized here), every contender. fn contested_vote_state(self: &PlatformClient, label: &str) -> VoteStateResult; - /// Every request sent by or to `identity`, created after `since_ms` - /// (0 = all). + /// The requests sent by or to `identity`, created after `since_ms` + /// (0 = all). A long backlog comes in parts (see `complete`). + /// `Unavailable` when more requests share one `created_at_ms` than + /// one call can return (100 before PV14): that does not clear on a + /// retry with the same `since_ms`. fn contact_requests( self: &PlatformClient, identity: &[u8; 32], diff --git a/packages/rs-sdk-cxx/src/marshal.rs b/packages/rs-sdk-cxx/src/marshal.rs index 810d14d8d6b..776f7a787c3 100644 --- a/packages/rs-sdk-cxx/src/marshal.rs +++ b/packages/rs-sdk-cxx/src/marshal.rs @@ -148,7 +148,7 @@ pub fn contact_request(document: &Document) -> Result() + .to_integer::() .map_err(|e| format!("contactRequest {name}: {e}")) }; let optional_bytes = |name: &str| { @@ -229,4 +229,31 @@ mod tests { properties.remove("toUserId"); assert!(contact_request(&document(properties)).is_err()); } + + /// The schema bounds the indices below only: one above u32 is passed + /// as stored, not narrowed or refused here. + #[test] + fn a_contact_request_index_above_u32_is_passed_as_stored() { + let above = u32::MAX as u64 + 1; + let document: Document = DocumentV0 { + properties: BTreeMap::from([ + ("toUserId".to_string(), Value::Identifier([2; 32])), + ("encryptedPublicKey".to_string(), Value::Bytes(vec![3; 96])), + ("senderKeyIndex".to_string(), Value::U64(above)), + ("recipientKeyIndex".to_string(), Value::I64(above as i64)), + ("accountReference".to_string(), Value::U64(u64::MAX)), + ]), + ..Default::default() + } + .into(); + let request = contact_request(&document).expect("marshals"); + assert_eq!( + ( + request.sender_key_index, + request.recipient_key_index, + request.account_reference + ), + (above, above, u64::MAX) + ); + } } diff --git a/packages/rs-sdk-cxx/tests/cxx_smoke.cc b/packages/rs-sdk-cxx/tests/cxx_smoke.cc index 859b943fba2..045d00fd808 100644 --- a/packages/rs-sdk-cxx/tests/cxx_smoke.cc +++ b/packages/rs-sdk-cxx/tests/cxx_smoke.cc @@ -138,7 +138,8 @@ int main() check(client->search_names("ali", 0).status.kind == StatusKind::Internal, "search limit 0 accepted"); check(unproved(client->pending_contests_for_identity(id).status), "pending_contests_for_identity"); check(unproved(client->contested_vote_state("a11ce").status), "contested_vote_state"); - check(unproved(client->contact_requests(id, ContactDirection::Received, 0).status), "contact_requests"); + const auto requests = client->contact_requests(id, ContactDirection::Received, 0); + check(unproved(requests.status) && !requests.complete, "contact_requests"); check(client->contact_requests(id, static_cast(9), 0).status.kind == StatusKind::Internal, "unknown contact direction accepted"); diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs b/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs index 798597b407e..b267d2f26a2 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs @@ -14,7 +14,7 @@ use crate::platform::documents::document_query::DocumentQuery; use crate::platform::FetchMany; use crate::{Error, Sdk}; use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; -use dpp::document::Document; +use dpp::document::{Document, DocumentV0Getters}; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; use dpp::platform_value::platform_value; @@ -62,12 +62,24 @@ const CONTACT_REQUEST_PAGE_SIZE: u32 = 100; /// continuation cursor is the recovery. const MAX_CONTACT_REQUEST_PAGES_PER_SWEEP: u32 = 50; +/// A contact-request sweep cut back to the part a caller can resume after +/// without skipping a request; see [`Sdk::fetch_sent_contact_requests_resumable`]. +#[derive(Debug, Clone)] +pub struct ResumableContactRequests { + /// Oldest first. + pub documents: ContactRequestDocuments, + /// Whether these are all the requests. If not, the rest are read by a + /// sweep with `after_created_at` set to the last document's `$createdAt`; + /// with no documents at all, no sweep can read past the first one's. + pub complete: bool, +} + impl Sdk { /// Drain `contactRequest` documents matching `filter_field == /// identity_id` (and, if `after_created_at` is set, `$createdAt > /// after_created_at`), paginating with a `StartAfter` document-id cursor - /// until a short/empty page proves exhaustion **or** the per-sweep page - /// budget ([`MAX_CONTACT_REQUEST_PAGES_PER_SWEEP`]) is hit. + /// until a short/empty page proves exhaustion **or** `max_pages` (at + /// most [`MAX_CONTACT_REQUEST_PAGES_PER_SWEEP`]) is hit. /// /// `Ok` ⇒ the fetch completed without error and the caller may advance its /// high-water mark to the max `$createdAt` fetched; a page error @@ -79,6 +91,7 @@ impl Sdk { filter_field: &str, identity_id: Identifier, after_created_at: Option, + max_pages: u32, ) -> Result { let dashpay_contract = self.fetch_dashpay_contract().await?; @@ -144,7 +157,7 @@ impl Sdk { // requests (`$createdAt ASC`), the caller advances its high-water // cursor to the max fetched, and the next sweep resumes from here — // the backlog drains oldest-first across sweeps, never buried. - if !should_fetch_another_contact_request_page(page_len, pages_fetched) { + if !should_fetch_another_contact_request_page(page_len, pages_fetched, max_pages) { if page_len >= CONTACT_REQUEST_PAGE_SIZE as usize { tracing::warn!( filter_field, @@ -172,8 +185,13 @@ impl Sdk { identity_id: Identifier, after_created_at: Option, ) -> Result { - self.fetch_contact_requests_paginated("$ownerId", identity_id, after_created_at) - .await + self.fetch_contact_requests_paginated( + "$ownerId", + identity_id, + after_created_at, + MAX_CONTACT_REQUEST_PAGES_PER_SWEEP, + ) + .await } /// Fetch contact requests **received** by `identity_id` (`toUserId ==`), @@ -183,10 +201,66 @@ impl Sdk { identity_id: Identifier, after_created_at: Option, ) -> Result { - self.fetch_contact_requests_paginated("toUserId", identity_id, after_created_at) + self.fetch_contact_requests_paginated( + "toUserId", + identity_id, + after_created_at, + MAX_CONTACT_REQUEST_PAGES_PER_SWEEP, + ) + .await + } + + /// [`Self::fetch_sent_contact_requests`], cut back to what a caller can + /// resume after with `after_created_at` = the last `$createdAt` read. + pub async fn fetch_sent_contact_requests_resumable( + &self, + identity_id: Identifier, + after_created_at: Option, + ) -> Result { + self.fetch_contact_requests_resumable("$ownerId", identity_id, after_created_at) + .await + } + + /// [`Self::fetch_received_contact_requests`], cut back to what a caller + /// can resume after with `after_created_at` = the last `$createdAt` read. + pub async fn fetch_received_contact_requests_resumable( + &self, + identity_id: Identifier, + after_created_at: Option, + ) -> Result { + self.fetch_contact_requests_resumable("toUserId", identity_id, after_created_at) .await } + async fn fetch_contact_requests_resumable( + &self, + filter_field: &str, + identity_id: Identifier, + after_created_at: Option, + ) -> Result { + // Both before and after the sweep: pages served across an upgrade to + // PV14 may include a lossy one. Before it only the first page can be + // kept, so no other is read. + let continues_before = cursor_continues(self); + let max_pages = if continues_before { + MAX_CONTACT_REQUEST_PAGES_PER_SWEEP + } else { + 1 + }; + let documents = self + .fetch_contact_requests_paginated( + filter_field, + identity_id, + after_created_at, + max_pages, + ) + .await?; + Ok(resumable_prefix( + documents, + continues_before && cursor_continues(self), + )) + } + /// Fetch both sent and received contact requests for an identity, each /// newer than `after_created_at` if given. pub async fn fetch_all_contact_requests_for_identity( @@ -206,38 +280,289 @@ impl Sdk { } /// Whether the contact-request pagination loop should fetch another page: -/// only when the last page was full (more may remain) AND the per-sweep page +/// only when the last page was full (more may remain) AND the sweep's page /// budget has not been reached. A short page (exhaustion) stops first and /// takes priority over the budget. -fn should_fetch_another_contact_request_page(last_page_len: usize, pages_fetched: u32) -> bool { - last_page_len >= CONTACT_REQUEST_PAGE_SIZE as usize - && pages_fetched < MAX_CONTACT_REQUEST_PAGES_PER_SWEEP +fn should_fetch_another_contact_request_page( + last_page_len: usize, + pages_fetched: u32, + max_pages: u32, +) -> bool { + last_page_len >= CONTACT_REQUEST_PAGE_SIZE as usize && pages_fetched < max_pages +} + +/// The prefix of a sweep (oldest first) a caller can resume after with +/// `after_created_at` = its last `$createdAt` without skipping a request, and +/// whether it is the whole sweep. The sweep pages on a document-id cursor that +/// is lossy at a page boundary in two cases: before protocol version 14 +/// (`!cursor_continues`) a continuation skips the rest of the boundary's +/// `$createdAt`, so only the first page is whole; and a sweep cut at its page +/// budget leaves the rest of its last `$createdAt` unread. Either way the +/// requests from that `$createdAt` on are dropped, to be read again next sweep. +fn resumable_prefix( + mut documents: ContactRequestDocuments, + cursor_continues: bool, +) -> ResumableContactRequests { + let page = CONTACT_REQUEST_PAGE_SIZE as usize; + let boundary = if !cursor_continues && documents.len() >= page { + page - 1 + } else if documents.len() >= page * MAX_CONTACT_REQUEST_PAGES_PER_SWEEP as usize { + documents.len() - 1 + } else { + return ResumableContactRequests { + documents, + complete: true, + }; + }; + let created_at = |document: &Option| document.as_ref().and_then(|d| d.created_at()); + let cut = documents + .get_index(boundary) + .and_then(|(_, document)| created_at(document)); + let keep = documents + .values() + .position(|document| created_at(document) >= cut) + .unwrap_or(boundary); + documents.truncate(keep); + ResumableContactRequests { + documents, + complete: false, + } +} + +/// Whether the SDK's protocol version continues a page inside a `$createdAt` +/// (PV14's query lowering). +fn cursor_continues(sdk: &Sdk) -> bool { + sdk.version() + .drive + .methods + .document + .query + .non_primary_key_path_query + != 0 } #[cfg(test)] mod tests { use super::{ - should_fetch_another_contact_request_page, CONTACT_REQUEST_PAGE_SIZE, - MAX_CONTACT_REQUEST_PAGES_PER_SWEEP, + resumable_prefix, should_fetch_another_contact_request_page, ContactRequestDocuments, + CONTACT_REQUEST_PAGE_SIZE, MAX_CONTACT_REQUEST_PAGES_PER_SWEEP, }; + use dpp::document::{Document, DocumentV0, DocumentV0Getters}; + use dpp::prelude::Identifier; + + /// One document per `$createdAt`, in sweep order. + fn sweep(created_at: impl IntoIterator) -> ContactRequestDocuments { + created_at + .into_iter() + .enumerate() + .map(|(i, created_at)| { + let mut id = [0; 32]; + id[..8].copy_from_slice(&(i as u64).to_be_bytes()); + let document = Document::V0(DocumentV0 { + id: Identifier::from(id), + created_at: Some(created_at), + ..Default::default() + }); + (Identifier::from(id), Some(document)) + }) + .collect() + } + + fn created_at(documents: ContactRequestDocuments, cursor_continues: bool) -> (Vec, bool) { + let resumable = resumable_prefix(documents, cursor_continues); + let created_at = resumable + .documents + .values() + .map(|document| document.as_ref().and_then(|d| d.created_at()).unwrap()) + .collect(); + (created_at, resumable.complete) + } + + /// Before PV14 the second page skips the rest of the first page's last + /// `$createdAt` (99 here), so only the requests before it are kept; from + /// PV14 on the sweep is whole. + #[test] + fn should_stop_a_sweep_before_a_lossy_page_boundary() { + let lossy = || sweep((0..98).chain([99; 2]).chain(100..150)); + assert_eq!(created_at(lossy(), false), ((0..98).collect(), false)); + assert_eq!( + created_at(lossy(), true), + ((0..98).chain([99; 2]).chain(100..150).collect(), true) + ); + // A full first page with nothing after it may still have lost its + // last `$createdAt` before PV14. + assert_eq!(created_at(sweep(0..100), false), ((0..99).collect(), false)); + // One short page is whole at any version. + assert_eq!(created_at(sweep(0..99), false), ((0..99).collect(), true)); + } + + /// A sweep cut at its page budget drops its last `$createdAt`. + #[test] + fn should_drop_the_last_created_at_of_a_sweep_cut_at_its_budget() { + let budget = (CONTACT_REQUEST_PAGE_SIZE * MAX_CONTACT_REQUEST_PAGES_PER_SWEEP) as u64; + assert_eq!( + created_at(sweep((0..budget - 2).chain([budget; 2])), true), + ((0..budget - 2).collect(), false) + ); + } + + /// A page of one `$createdAt` cannot be resumed after: no documents, and + /// not complete. + #[test] + fn should_keep_nothing_of_a_page_sharing_one_created_at() { + assert_eq!(created_at(sweep([5; 150]), false), (vec![], false)); + } #[test] fn pagination_continues_until_budget_then_stops() { let full = CONTACT_REQUEST_PAGE_SIZE as usize; // Full page, still under budget → keep draining. - assert!(should_fetch_another_contact_request_page(full, 1)); + let budget = MAX_CONTACT_REQUEST_PAGES_PER_SWEEP; + assert!(should_fetch_another_contact_request_page(full, 1, budget)); assert!(should_fetch_another_contact_request_page( full, - MAX_CONTACT_REQUEST_PAGES_PER_SWEEP - 1 + budget - 1, + budget )); // Full page, budget reached → stop (the high-water cursor resumes the // remainder next sweep; a spam flood can't force an unbounded fetch). assert!(!should_fetch_another_contact_request_page( - full, - MAX_CONTACT_REQUEST_PAGES_PER_SWEEP + full, budget, budget )); + // A one-page sweep stops after its first full page. + assert!(!should_fetch_another_contact_request_page(full, 1, 1)); // A short page is exhaustion — stop regardless of how few pages ran. - assert!(!should_fetch_another_contact_request_page(full - 1, 1)); - assert!(!should_fetch_another_contact_request_page(0, 1)); + assert!(!should_fetch_another_contact_request_page( + full - 1, + 1, + budget + )); + assert!(!should_fetch_another_contact_request_page(0, 1, budget)); + } + + #[cfg(feature = "mocks")] + mod with_mock_sdk { + use super::*; + use crate::platform::documents::document_query::DocumentQuery; + use crate::{Sdk, SdkBuilder}; + use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::DataContract; + use dpp::platform_value::platform_value; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + use drive::query::{OrderClause, WhereClause, WhereOperator}; + use std::sync::Arc; + + /// Serves the DashPay contract (nothing else), so the sweep's contract lookup never + /// reaches the network or a dump directory + struct DashpayOnlyContextProvider(Arc); + + impl dash_context_provider::ContextProvider for DashpayOnlyContextProvider { + fn get_data_contract( + &self, + id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, crate::error::ContextProviderError> { + Ok((*id == self.0.id()).then(|| Arc::clone(&self.0))) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result< + Option< + dpp::data_contract::associated_token::token_configuration::TokenConfiguration, + >, + crate::error::ContextProviderError, + > { + Ok(None) + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], crate::error::ContextProviderError> { + Err(crate::error::ContextProviderError::Config( + "no quorum keys in this test".to_string(), + )) + } + + fn get_platform_activation_height( + &self, + ) -> Result + { + Ok(1) + } + } + + /// The page of requests to `recipient` from `start`, built here rather than with the + /// code under test, so a request shaped differently matches no expectation + fn received_page_query( + contract: &Arc, + recipient: Identifier, + start: Option, + ) -> DocumentQuery { + DocumentQuery { + select: drive::query::SelectProjection::documents(), + data_contract: Arc::clone(contract), + document_type_name: "contactRequest".to_string(), + where_clauses: vec![WhereClause { + field: "toUserId".to_string(), + operator: WhereOperator::Equal, + value: platform_value!(recipient), + }], + time_range_clauses: vec![], + integer_range_clauses: vec![], + sub_queries: vec![], + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }], + limit: CONTACT_REQUEST_PAGE_SIZE, + offset: None, + start, + } + } + + /// Before PV14 only the first page of a sweep can be kept, so a backlog of several + /// pages costs one request: the mock answers only the first page, and asking for the + /// second would fail the sweep + #[tokio::test] + async fn should_read_one_page_of_a_resumable_sweep_before_pv14() { + let version = PlatformVersion::get(13).expect("expected protocol version 13"); + let contract = Arc::new( + load_system_data_contract(SystemDataContract::Dashpay, version) + .expect("expected the dashpay contract"), + ); + let mut sdk: Sdk = SdkBuilder::new_mock() + .with_version(version) + .with_context_provider(DashpayOnlyContextProvider(Arc::clone(&contract))) + .build() + .expect("expected a mock sdk"); + let recipient = Identifier::new([7; 32]); + sdk.mock() + .expect_fetch_many::( + received_page_query(&contract, recipient, None), + Some(sweep(0..CONTACT_REQUEST_PAGE_SIZE as u64)), + ) + .await + .expect("expected to register the first page"); + + let resumable = sdk + .fetch_received_contact_requests_resumable(recipient, None) + .await + .expect("expected one page to answer"); + + assert_eq!( + resumable.documents.len(), + CONTACT_REQUEST_PAGE_SIZE as usize - 1 + ); + assert!(!resumable.complete); + } } } diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 3789065cd31..da89e8eec52 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -14,7 +14,7 @@ pub use contact_request::{ sender_key_purpose_is_acceptable_on_receive, ContactRequestInput, ContactRequestResult, EcdhProvider, RecipientIdentity, SendContactRequestInput, SendContactRequestResult, }; -pub use contact_request_queries::ContactRequestDocuments; +pub use contact_request_queries::{ContactRequestDocuments, ResumableContactRequests}; pub use invitation::{ encode_invitation_uri, parse_invitation_uri, voucher_credit_output, voucher_output_index, wif_network_matches, InvitationClaimStatus, InvitationError, InviterInfo, ParsedInvitation, From 4d456515f287ef3aa84f385fca7605f187153274 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 30 Oct 2026 01:08:38 +0000 Subject: [PATCH 5/5] build(docker): copy rs-sdk-cxx into the Rust build stages The new workspace member was missing from the stages that copy the workspace package by package, so cargo chef prepare could not load the workspace and the Drive, RS-DAPI and Dashmate helper images failed. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 4868fea42d9..4ba62ab9d8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -423,6 +423,7 @@ COPY --parents \ packages/dash-platform-queries \ packages/rs-sdk \ packages/rs-sdk-ffi \ + packages/rs-sdk-cxx \ packages/rs-unified-sdk-ffi \ packages/rs-unified-sdk-jni \ packages/check-features \ @@ -553,6 +554,7 @@ COPY --parents \ packages/dash-platform-queries \ packages/rs-sdk \ packages/rs-sdk-ffi \ + packages/rs-sdk-cxx \ packages/rs-unified-sdk-ffi \ packages/rs-unified-sdk-jni \ packages/check-features \ @@ -987,6 +989,7 @@ COPY --parents \ packages/dash-platform-queries \ packages/rs-sdk \ packages/rs-sdk-ffi \ + packages/rs-sdk-cxx \ packages/rs-unified-sdk-ffi \ packages/rs-unified-sdk-jni \ packages/rs-platform-wallet \