From 42a22044977cd3ed573a1feb782ab17e67ffb454 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 01:33:10 -0500 Subject: [PATCH 01/20] build: make the workspace lockfile vendorable again Since #5307 bumped rust-dashcore, the lockfile holds base58ck 0.5.0, bitcoin_hashes 1.2.0, bitcoin-internals 0.7.0 and bitcoin-units 0.5.0 twice: from crates.io (via base-sdk's dash-pkc, dash-num and dash-types, and rust-dashcore's dashcore-crypto) and from rust-bitcoin rev 7ba35c7c (via bitcoin-crypto in dashcore-crypto). `cargo vendor --locked` cannot put one name and version from two sources into a single directory source, so it fails with "found duplicate version of package `base58ck v0.5.0` vendored from two sources". Patch crates.io to the same rust-bitcoin rev for those four crates, so each has one source again. bitcoin-internals 0.5.0 and hex-conservative 0.3.2, used only by the crates.io bitcoin-units, drop out of the lock. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 94 +++++++++++------------------------------------------- Cargo.toml | 11 +++++++ 2 files changed, 30 insertions(+), 75 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84af3bdb6e2..72ae0b61e84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -500,23 +500,13 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6107fe1be6682a68940da878d9e9f5e90ca5745b3dec9fd1bb393c8777d4f581" -[[package]] -name = "base58ck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df540278d807651653f9b6f21e18903d790d7769eadfbc0dc4df0a141260318f" -dependencies = [ - "bitcoin-internals 0.6.0", - "bitcoin_hashes 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "base58ck" version = "0.5.0" source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" dependencies = [ - "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", - "bitcoin_hashes 1.2.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin-internals 0.7.0", + "bitcoin_hashes 1.2.0", ] [[package]] @@ -661,7 +651,7 @@ name = "bitcoin-consensus-encoding" version = "1.2.0" source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" dependencies = [ - "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin-internals 0.7.0", "hex-conservative 1.3.0", "serde", ] @@ -672,7 +662,7 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f" dependencies = [ - "bitcoin-internals 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-internals 0.7.0", "hex-conservative 1.3.0", "serde", ] @@ -682,37 +672,22 @@ name = "bitcoin-crypto" version = "0.3.0" source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" dependencies = [ - "base58ck 0.5.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", - "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "base58ck", + "bitcoin-internals 0.7.0", "bitcoin-network-kind", "bitcoin-primitives 0.103.1", - "bitcoin_hashes 1.2.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin_hashes 1.2.0", "hex-conservative 1.3.0", "secp256k1", "serde", ] -[[package]] -name = "bitcoin-internals" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" -dependencies = [ - "hex-conservative 0.3.2", -] - [[package]] name = "bitcoin-internals" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" -[[package]] -name = "bitcoin-internals" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2" - [[package]] name = "bitcoin-internals" version = "0.7.0" @@ -735,7 +710,7 @@ name = "bitcoin-network-kind" version = "1.0.0" source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" dependencies = [ - "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin-internals 0.7.0", "serde", ] @@ -747,8 +722,8 @@ checksum = "82e12cb1814d037edbd667554c2c0e6b067db72c83ee935b645a6b5829aca908" dependencies = [ "bitcoin-consensus-encoding 1.3.0", "bitcoin-internals 0.6.0", - "bitcoin-units 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", - "bitcoin_hashes 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-units", + "bitcoin_hashes 1.2.0", "hex-conservative 1.3.0", ] @@ -758,30 +733,19 @@ version = "0.103.1" source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" dependencies = [ "bitcoin-consensus-encoding 1.2.0", - "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", - "bitcoin-units 0.5.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", - "bitcoin_hashes 1.2.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin-internals 0.7.0", + "bitcoin-units", + "bitcoin_hashes 1.2.0", "hex-conservative 1.3.0", ] -[[package]] -name = "bitcoin-units" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae6dcb008158c2bdbe698470280439844be5f640310e4f5d6ef0a0209e066c2" -dependencies = [ - "bitcoin-consensus-encoding 1.3.0", - "bitcoin-internals 0.5.0", - "serde", -] - [[package]] name = "bitcoin-units" version = "0.5.0" source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" dependencies = [ "bitcoin-consensus-encoding 1.2.0", - "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin-internals 0.7.0", "serde", ] @@ -796,24 +760,13 @@ dependencies = [ "serde", ] -[[package]] -name = "bitcoin_hashes" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" -dependencies = [ - "bitcoin-consensus-encoding 1.3.0", - "bitcoin-internals 0.6.0", - "hex-conservative 1.3.0", -] - [[package]] name = "bitcoin_hashes" version = "1.2.0" source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" dependencies = [ "bitcoin-consensus-encoding 1.2.0", - "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin-internals 0.7.0", "hex-conservative 1.3.0", "serde", ] @@ -1848,7 +1801,7 @@ source = "git+https://github.com/dashpay/base-sdk?rev=33fcc59e1868c803f9608e0e78 dependencies = [ "ambassador", "bitcoin-consensus-encoding 1.3.0", - "bitcoin-internals 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-internals 0.7.0", "dash-types", "delegate", "hex-conservative 1.3.0", @@ -1861,8 +1814,8 @@ version = "0.1.0-beta" source = "git+https://github.com/dashpay/base-sdk?rev=33fcc59e1868c803f9608e0e78ee8bddf3abcee7#33fcc59e1868c803f9608e0e78ee8bddf3abcee7" dependencies = [ "aes 0.9.3", - "base58ck 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", - "bitcoin_hashes 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "base58ck", + "bitcoin_hashes 1.2.0", "blst", "cfg-if", "dash-num", @@ -2053,7 +2006,7 @@ name = "dashcore-crypto" version = "0.45.0" source = "git+https://github.com/dashpay/rust-dashcore?rev=90359ed58a993bab0d4e267b4029bab132a56532#90359ed58a993bab0d4e267b4029bab132a56532" dependencies = [ - "base58ck 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "base58ck", "bitcoin-crypto", "dash-network", "dash-pkc", @@ -3718,15 +3671,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "hex-conservative" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" -dependencies = [ - "arrayvec", -] - [[package]] name = "hex-conservative" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index e89b443e2b2..2156c0ab1f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,17 @@ dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "90359e dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "90359ed58a993bab0d4e267b4029bab132a56532" } tokio-metrics = "0.5" +# base-sdk and rust-dashcore take these crates from crates.io, while +# rust-dashcore's bitcoin-crypto takes the same versions from this rust-bitcoin +# rev. Cargo cannot vendor one name and version from two sources, so use the +# git copies for both. Keep the rev equal to bitcoin-crypto's rev in +# rust-dashcore; drop this once they share one source. +[patch.crates-io] +base58ck = { git = "https://github.com/rust-bitcoin/rust-bitcoin", rev = "7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" } +bitcoin_hashes = { git = "https://github.com/rust-bitcoin/rust-bitcoin", rev = "7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" } +bitcoin-internals = { git = "https://github.com/rust-bitcoin/rust-bitcoin", rev = "7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" } +bitcoin-units = { git = "https://github.com/rust-bitcoin/rust-bitcoin", rev = "7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" } + # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which # otherwise ships huge. Inherits `release` and is ONLY used by the iOS # build (`build_ios.sh --profile release`) From 4e4385f3b1defa2b95fc5988560c284466261989 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 01:09:03 -0500 Subject: [PATCH 02/20] 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 | 218 ++++++ packages/rs-sdk-cxx/src/signer.rs | 187 +++++ packages/rs-sdk-cxx/tests/cxx_smoke.cc | 312 ++++++++ 13 files changed, 2798 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 72ae0b61e84..4c5af7524ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1302,6 +1302,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" @@ -1660,6 +1671,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.0-beta.6" @@ -1920,6 +1993,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dash-sdk-cxx" +version = "5.0.0-beta.6" +dependencies = [ + "async-trait", + "cxx", + "cxx-build", + "dash-sdk", + "platform-encryption", + "tokio", + "zeroize", +] + [[package]] name = "dash-spv" version = "0.45.0" @@ -4550,6 +4636,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" @@ -7024,6 +7119,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" @@ -7784,6 +7885,15 @@ dependencies = [ "zip 8.6.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termtree" version = "0.5.1" @@ -8606,6 +8716,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 2156c0ab1f8..e2e1d7b96dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ members = [ "packages/app-connect-contract", "packages/moderation-charters-contract", "packages/rs-sdk-ffi", + "packages/rs-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..8666a556a1a --- /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::secp256k1::{PublicKey, SecretKey}; +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::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..6877352ac69 --- /dev/null +++ b/packages/rs-sdk-cxx/src/runtime.rs @@ -0,0 +1,218 @@ +// 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::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 worker = { + let runtime = Arc::clone(&runtime); + std::thread::spawn(move || { + let call = runtime.call().expect("open"); + call.run(async { tokio::time::sleep(Duration::from_secs(60)).await }) + }) + }; + std::thread::sleep(Duration::from_millis(100)); + let started = std::time::Instant::now(); + runtime.shutdown(|| ()); + assert!(matches!(worker.join().unwrap(), Err(RunError::ShutDown))); + assert!(started.elapsed() < Duration::from_secs(30)); + 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) = std::sync::mpsc::channel(); + let finished = Arc::new(AtomicBool::new(false)); + let worker = { + let (runtime, finished) = (Arc::clone(&runtime), Arc::clone(&finished)); + std::thread::spawn(move || { + let call = runtime.call().expect("open"); + let _ = call.run(async move { + blocking(|| { + entered.send(()).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + }) + }); + finished.store(true, Ordering::SeqCst); + }) + }; + entered_rx.recv().unwrap(); + runtime.shutdown(|| ()); + assert!( + finished.load(Ordering::SeqCst), + "shutdown returned before the call ended" + ); + 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..a8d08ebf1ad --- /dev/null +++ b/packages/rs-sdk-cxx/src/signer.rs @@ -0,0 +1,187 @@ +// 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::secp256k1::ecdsa::{RecoverableSignature, RecoveryId}; +use dash_sdk::dpp::dashcore::secp256k1::{ecdsa, Message, PublicKey}; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::signer::Signer; +use dash_sdk::dpp::identity::{IdentityPublicKey, KeyType}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::key_wallet::signer::{Signer as KeyWalletSigner, SignerMethod}; +use dash_sdk::dpp::platform_value::BinaryData; +use dash_sdk::dpp::ProtocolError; + +use crate::ffi; +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<(ecdsa::Signature, PublicKey), 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(), public_key)) + } + + 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::dashcore::secp256k1::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 dc4dc8e80ef8c495be693d4cc19c378a94f5d116 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 01:09:08 -0500 Subject: [PATCH 03/20] 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 b74139a2523..b554e0bdd51 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -131,6 +131,9 @@ dash-platform-queries: dash-sdk: - packages/rs-sdk/** +dash-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 5c59193d383..eb69082a10b 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -153,6 +153,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 91dd8c681ba..eca37c69887 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -181,6 +181,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 353954df18a..69809577243 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -269,6 +269,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: | @@ -455,6 +479,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 e867462347c60a7fca3cbe3d9b0c7e9c73a1ed48 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 00:23:43 -0500 Subject: [PATCH 04/20] feat(sdk): DashPay profile create, update and fetch Add Sdk::get_profiles, create_profile and update_profile for the DIP-15 profile document, built on document_create and document_replace. The caller passes a precomputed avatar hash and fingerprint, so rs-sdk needs no image decoder. update_profile reads the existing document, overlays only the fields that are set, increments the revision and replaces it. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/Cargo.toml | 1 + packages/rs-sdk/src/platform/dashpay/mod.rs | 4 +- .../rs-sdk/src/platform/dashpay/profile.rs | 423 ++++++++++++++++++ 3 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 packages/rs-sdk/src/platform/dashpay/profile.rs diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 9a6d98b6622..64f0486d29e 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -70,6 +70,7 @@ dpp = { path = "../rs-dpp", default-features = false, features = [ "client", "validation", "random-documents", + "dashpay-contract", ] } tokio-test = { version = "0.4.4" } clap = { version = "4.5.4", features = ["derive"] } diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 9ca2704be31..57362969519 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -1,10 +1,11 @@ //! DashPay contact request helpers //! //! This module provides helper functions for creating and sending DashPay contact requests -//! according to DIP-15 specification. +//! and for creating, updating and fetching DashPay profiles according to DIP-15 specification. mod contact_request; mod contact_request_queries; +mod profile; pub use contact_request::{ recipient_key_purpose_is_acceptable_on_receive, recipient_key_purpose_is_valid, @@ -12,6 +13,7 @@ pub use contact_request::{ EcdhProvider, RecipientIdentity, SendContactRequestInput, SendContactRequestResult, }; pub use contact_request_queries::ContactRequestDocuments; +pub use profile::{ProfileFields, MAX_PROFILE_OWNERS_PER_QUERY}; use crate::{Error, Sdk}; use dpp::prelude::Identifier; diff --git a/packages/rs-sdk/src/platform/dashpay/profile.rs b/packages/rs-sdk/src/platform/dashpay/profile.rs new file mode 100644 index 00000000000..4aeedf04dde --- /dev/null +++ b/packages/rs-sdk/src/platform/dashpay/profile.rs @@ -0,0 +1,423 @@ +//! DashPay profile create, update and fetch +//! +//! Implements the DIP-15 `profile` document. Avatar image hashing is the +//! caller's job: [`ProfileFields`] carries the precomputed SHA-256 `avatarHash` +//! and dHash `avatarFingerprint`, so the SDK needs no image decoder. + +use crate::platform::documents::document_query::DocumentQuery; +use crate::platform::documents::transitions::{ + DocumentCreateResult, DocumentCreateTransitionBuilder, DocumentReplaceResult, + DocumentReplaceTransitionBuilder, +}; +use crate::platform::FetchMany; +use crate::{Error, Sdk}; +use dpp::dashcore::secp256k1::rand::rngs::StdRng; +use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::DataContract; +use dpp::document::{Document, DocumentV0, DocumentV0Getters, INITIAL_REVISION}; +use dpp::identity::signer::Signer; +use dpp::identity::IdentityPublicKey; +use dpp::platform_value::{platform_value, Value}; +use dpp::prelude::Identifier; +use drive::query::{OrderClause, WhereClause, WhereOperator}; +use std::collections::BTreeMap; +use std::sync::Arc; + +const PROFILE_DOCUMENT_TYPE: &str = "profile"; + +/// Most owners one [`Sdk::get_profiles`] call can look up: the Platform `In` +/// clause cardinality cap, which is also the document-query limit. +pub const MAX_PROFILE_OWNERS_PER_QUERY: usize = 100; + +/// The profile fields a caller sets. `None` leaves the field alone on +/// [`Sdk::update_profile`] and omits it on [`Sdk::create_profile`]. +/// +/// `avatar_hash` and `avatar_fingerprint` are computed by the caller from the +/// avatar image bytes and set together with a new `avatar_url`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProfileFields { + /// Display name (max 25 chars per DIP-15) + pub display_name: Option, + /// Public message, the bio (max 140 chars per DIP-15) + pub public_message: Option, + /// Avatar URL (HTTPS, max 2048 chars per DIP-15) + pub avatar_url: Option, + /// SHA-256 hash of the avatar image bytes + pub avatar_hash: Option<[u8; 32]>, + /// dHash perceptual fingerprint of the avatar image + pub avatar_fingerprint: Option<[u8; 8]>, +} + +impl ProfileFields { + /// Overlay the fields that are set onto `properties`, leaving the rest as + /// they are. + fn overlay(self, mut properties: BTreeMap) -> BTreeMap { + if let Some(name) = self.display_name { + properties.insert("displayName".to_string(), Value::Text(name)); + } + if let Some(message) = self.public_message { + properties.insert("publicMessage".to_string(), Value::Text(message)); + } + if let Some(url) = self.avatar_url { + properties.insert("avatarUrl".to_string(), Value::Text(url)); + } + if let Some(hash) = self.avatar_hash { + properties.insert("avatarHash".to_string(), Value::Bytes32(hash)); + } + if let Some(fingerprint) = self.avatar_fingerprint { + properties.insert( + "avatarFingerprint".to_string(), + Value::Bytes(fingerprint.to_vec()), + ); + } + properties + } +} + +impl Sdk { + /// Fetch the `profile` documents of `owner_ids` (unique, at most + /// [`MAX_PROFILE_OWNERS_PER_QUERY`]), keyed by owner id. An owner without a + /// profile is missing from the result. More than that many owners, or a + /// duplicated owner, is rejected with [`Error::Generic`] before any query. + /// + /// `dashpay_contract` is the DashPay data contract the caller resolved. + pub async fn get_profiles( + &self, + dashpay_contract: &Arc, + owner_ids: &[Identifier], + ) -> Result, Error> { + ensure_valid_profile_owners(owner_ids)?; + if owner_ids.is_empty() { + return Ok(BTreeMap::new()); + } + self.fetch_profile_documents(profiles_query(dashpay_contract, owner_ids)) + .await + } + + /// Create the profile document of `owner_id` from `fields`. + /// + /// `signing_key` must be a HIGH or CRITICAL authentication key of the owner. + /// Returns the document as confirmed by Platform. + pub async fn create_profile>( + &self, + dashpay_contract: &Arc, + owner_id: Identifier, + fields: ProfileFields, + signing_key: &IdentityPublicKey, + signer: &S, + ) -> Result { + let properties = sanitized_properties(dashpay_contract, fields.overlay(BTreeMap::new()))?; + + let entropy: [u8; 32] = StdRng::from_os_rng().random(); + let id = Document::generate_document_id_v0( + &dashpay_contract.id(), + &owner_id, + PROFILE_DOCUMENT_TYPE, + entropy.as_slice(), + ); + + let builder = DocumentCreateTransitionBuilder::new( + Arc::clone(dashpay_contract), + PROFILE_DOCUMENT_TYPE.to_string(), + profile_document(id, owner_id, properties, None), + entropy, + ); + let DocumentCreateResult::Document(document) = + self.document_create(builder, signing_key, signer).await?; + Ok(document) + } + + /// Update the existing profile document of `owner_id`: read it, overlay the + /// `fields` that are set so a partial update keeps the other fields, + /// increment the revision and replace it. + /// + /// `signing_key` must be a HIGH or CRITICAL authentication key of the owner. + /// Returns the merged document as confirmed by Platform. Fails if the owner + /// has no profile. + pub async fn update_profile>( + &self, + dashpay_contract: &Arc, + owner_id: Identifier, + fields: ProfileFields, + signing_key: &IdentityPublicKey, + signer: &S, + ) -> Result { + let existing = self + .fetch_profile_documents(owner_profile_query(dashpay_contract, owner_id)) + .await? + .remove(&owner_id) + .ok_or_else(|| { + Error::Generic("No existing profile document found to update".to_string()) + })?; + + let builder = DocumentReplaceTransitionBuilder::new( + Arc::clone(dashpay_contract), + PROFILE_DOCUMENT_TYPE.to_string(), + replacement_document(&existing, fields, dashpay_contract)?, + ); + let DocumentReplaceResult::Document(document) = + self.document_replace(builder, signing_key, signer).await?; + Ok(document) + } + + async fn fetch_profile_documents( + &self, + query: DocumentQuery, + ) -> Result, Error> { + Ok(Document::fetch_many(self, query) + .await? + .into_values() + .flatten() + .map(|document| (document.owner_id(), document)) + .collect()) + } +} + +/// Refuse owner lists Platform would reject: more than +/// [`MAX_PROFILE_OWNERS_PER_QUERY`] owners, or a duplicated owner. +fn ensure_valid_profile_owners(owner_ids: &[Identifier]) -> Result<(), Error> { + if owner_ids.len() > MAX_PROFILE_OWNERS_PER_QUERY { + return Err(Error::Generic(format!( + "at most {MAX_PROFILE_OWNERS_PER_QUERY} profile owners per query, got {}", + owner_ids.len() + ))); + } + let mut seen = std::collections::BTreeSet::new(); + if let Some(duplicate) = owner_ids.iter().find(|id| !seen.insert(**id)) { + return Err(Error::Generic(format!( + "duplicate profile owner {duplicate} in query" + ))); + } + Ok(()) +} + +/// The replacement of `existing`: same id and owner, `fields` overlaid on its +/// properties so a partial update keeps the others, revision incremented. +fn replacement_document( + existing: &Document, + fields: ProfileFields, + dashpay_contract: &DataContract, +) -> Result { + let properties = sanitized_properties( + dashpay_contract, + fields.overlay(existing.properties().clone()), + )?; + let revision = existing.revision().unwrap_or(INITIAL_REVISION) + 1; + Ok(profile_document( + existing.id(), + existing.owner_id(), + properties, + Some(revision), + )) +} + +/// Normalize `properties` to the representations the profile schema expects. +fn sanitized_properties( + dashpay_contract: &DataContract, + mut properties: BTreeMap, +) -> Result, Error> { + dashpay_contract + .document_type_for_name(PROFILE_DOCUMENT_TYPE) + .map_err(|e| Error::Protocol(e.into()))? + .sanitize_document_properties(&mut properties); + Ok(properties) +} + +fn profile_document( + id: Identifier, + owner_id: Identifier, + properties: BTreeMap, + revision: Option, +) -> Document { + Document::V0(DocumentV0 { + id, + owner_id, + properties, + revision, + ..Default::default() + }) +} + +/// Build the `$ownerId In [owner_ids]` profile query. +/// +/// `In` is a range operator, so DAPI requires a matching `orderBy` on the +/// range field or it rejects the query with "missing order by for range". The +/// `$ownerId` index is unique, so ordering does not change the result set +/// (at most one profile per owner). +fn profiles_query(dashpay_contract: &Arc, owner_ids: &[Identifier]) -> DocumentQuery { + DocumentQuery { + select: drive::query::SelectProjection::documents(), + data_contract: Arc::clone(dashpay_contract), + document_type_name: PROFILE_DOCUMENT_TYPE.to_string(), + where_clauses: vec![WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::In, + value: Value::Array(owner_ids.iter().map(|id| platform_value!(id)).collect()), + }], + time_range_clauses: vec![], + integer_range_clauses: vec![], + sub_queries: vec![], + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "$ownerId".to_string(), + ascending: true, + }], + limit: MAX_PROFILE_OWNERS_PER_QUERY as u32, + offset: None, + start: None, + } +} + +/// Build the single-owner `profile` query `$ownerId == owner_id` (limit 1: a +/// profile is unique per owner). +fn owner_profile_query( + dashpay_contract: &Arc, + owner_id: Identifier, +) -> DocumentQuery { + DocumentQuery { + select: drive::query::SelectProjection::documents(), + data_contract: Arc::clone(dashpay_contract), + document_type_name: PROFILE_DOCUMENT_TYPE.to_string(), + where_clauses: vec![WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: platform_value!(owner_id), + }], + time_range_clauses: vec![], + integer_range_clauses: vec![], + sub_queries: vec![], + group_by: vec![], + having: vec![], + order_by_clauses: vec![], + limit: 1, + offset: None, + start: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::system_data_contracts::load_system_data_contract; + use dpp::version::PlatformVersion; + + fn existing_full() -> BTreeMap { + ProfileFields { + display_name: Some("Alice".into()), + public_message: Some("hello world".into()), + avatar_url: Some("https://x/a.png".into()), + avatar_hash: Some([7u8; 32]), + avatar_fingerprint: Some([1, 2, 3, 4, 5, 6, 7, 8]), + } + .overlay(BTreeMap::new()) + } + + fn text<'a>(properties: &'a BTreeMap, key: &str) -> Option<&'a str> { + properties.get(key).and_then(|v| v.as_str()) + } + + fn dashpay_contract() -> DataContract { + load_system_data_contract( + dpp::data_contracts::SystemDataContract::Dashpay, + PlatformVersion::latest(), + ) + .expect("DashPay system contract loads") + } + + /// A partial update (only `displayName`) yields a replacement of the same + /// document with the sibling fields kept and the revision incremented. + #[test] + fn partial_update_replaces_the_same_document_keeping_sibling_fields() { + let (id, owner) = (Identifier::new([0xD1; 32]), Identifier::new([0x0E; 32])); + let existing = profile_document(id, owner, existing_full(), Some(3)); + let fields = ProfileFields { + display_name: Some("Alice 2".to_string()), + ..Default::default() + }; + + let replacement = replacement_document(&existing, fields, &dashpay_contract()).unwrap(); + + assert_eq!(replacement.id(), id); + assert_eq!(replacement.owner_id(), owner); + assert_eq!(replacement.revision(), Some(4)); + let merged = replacement.properties(); + assert_eq!(text(merged, "displayName"), Some("Alice 2")); + assert_eq!(text(merged, "publicMessage"), Some("hello world")); + assert_eq!(text(merged, "avatarUrl"), Some("https://x/a.png")); + assert_eq!(merged.get("avatarHash"), Some(&Value::Bytes32([7u8; 32]))); + assert_eq!( + merged.get("avatarFingerprint"), + Some(&Value::Bytes(vec![1, 2, 3, 4, 5, 6, 7, 8])) + ); + } + + /// `get_profiles` refuses too many owners and duplicated owners locally. + #[test] + fn profile_owners_over_the_cap_or_duplicated_are_rejected() { + let ids = |n: usize| -> Vec { + (0..n).map(|i| Identifier::new([i as u8 + 1; 32])).collect() + }; + + assert!(ensure_valid_profile_owners(&[]).is_ok()); + assert!(ensure_valid_profile_owners(&ids(MAX_PROFILE_OWNERS_PER_QUERY)).is_ok()); + assert!(matches!( + ensure_valid_profile_owners(&ids(MAX_PROFILE_OWNERS_PER_QUERY + 1)), + Err(Error::Generic(_)) + )); + + let mut duplicated = ids(2); + duplicated.push(duplicated[0]); + assert!(matches!( + ensure_valid_profile_owners(&duplicated), + Err(Error::Generic(_)) + )); + } + + /// A supplied avatar hash and fingerprint replace the existing ones. + #[test] + fn avatar_replaced_when_supplied() { + let replaced = ProfileFields { + avatar_hash: Some([9u8; 32]), + avatar_fingerprint: Some([9u8; 8]), + ..Default::default() + } + .overlay(existing_full()); + assert_eq!(replaced.get("avatarHash"), Some(&Value::Bytes32([9u8; 32]))); + assert_eq!( + replaced.get("avatarFingerprint"), + Some(&Value::Bytes(vec![9u8; 8])) + ); + } + + /// DAPI rejects any query that uses a range where-operator without a + /// matching `orderBy` on the range field ("missing order by for range"). + /// The profile query filters by `$ownerId In [...]`, a range operator, so + /// every range clause it builds must carry an `orderBy` on its field. + /// Guarded against vacuous truth: the query must actually contain a range + /// clause, otherwise the invariant would pass trivially. + #[test] + fn profiles_query_orders_by_every_range_field() { + let contract = Arc::new(dashpay_contract()); + let owners = vec![Identifier::new([1u8; 32]), Identifier::new([2u8; 32])]; + + let query = profiles_query(&contract, &owners); + + assert!( + query.where_clauses.iter().any(|wc| wc.operator.is_range()), + "expected a range where-clause (e.g. $ownerId In [...])" + ); + for wc in &query.where_clauses { + if wc.operator.is_range() { + assert!( + query.order_by_clauses.iter().any(|oc| oc.field == wc.field), + "range clause on `{}` has no matching orderBy; DAPI rejects \ + this with \"missing order by for range\"", + wc.field + ); + } + } + } +} From 2ac3789698c02d450c55e7cfeaa8776367e765bb Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 00:26:22 -0500 Subject: [PATCH 05/20] refactor(platform-wallet): use the SDK for DashPay profile create, update and fetch Profile document build, the update read-merge-replace and the profile fetch now live in dash-sdk. The wallet keeps avatar hashing, signing-key selection and the profile cache, and calls Sdk::create_profile, update_profile and get_profiles. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/profile.rs | 498 +++--------------- 1 file changed, 84 insertions(+), 414 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index c888194d9a6..933a9b91aa1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -13,6 +13,8 @@ use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::Value; use dpp::prelude::Identifier; +use dash_sdk::platform::dashpay::{ProfileFields, MAX_PROFILE_OWNERS_PER_QUERY}; + use super::*; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; @@ -45,7 +47,7 @@ impl DashPayView<'_, B> { /// Fetch DashPay profile documents from Platform for all managed /// identities and cache them on [`ManagedIdentity`]. /// - /// Fetches in `In`-chunks (one query per ≤`CONTACT_PROFILE_IN_CAP` ids, not + /// Fetches in `In`-chunks (one query per ≤`MAX_PROFILE_OWNERS_PER_QUERY` ids, not /// N+1) with per-chunk failure isolation, and persists only when the /// fetched profile differs from the cached one — a `None` result clears the /// cache only when a profile is currently stored (both arms guarded so a @@ -78,7 +80,7 @@ impl DashPayView<'_, B> { // chunk but absent from the result is confirmed-absent (`None`). let mut fetched: std::collections::BTreeMap> = std::collections::BTreeMap::new(); - for chunk in identity_ids.chunks(CONTACT_PROFILE_IN_CAP) { + for chunk in identity_ids.chunks(MAX_PROFILE_OWNERS_PER_QUERY) { match self .fetch_contact_profiles_chunk(&dashpay_contract, chunk) .await @@ -147,41 +149,14 @@ impl DashPayView<'_, B> { S: Signer + Send + Sync, { use dpp::data_contract::accessors::v0::DataContractV0Getters; - use dpp::document::Document; - use dpp::document::DocumentV0; // 1. The DashPay data contract (process-wide cache). let dashpay_contract = super::dashpay_contract()?; - // 2. Compute avatar hashes when raw bytes are provided. - let (avatar_hash, avatar_fingerprint) = if let Some(ref bytes) = input.avatar_bytes { - let hash = crate::wallet::identity::calculate_avatar_hash(bytes); - let fingerprint = crate::wallet::identity::calculate_dhash_fingerprint(bytes) - .map_err(PlatformWalletError::InvalidIdentityData)?; - (Some(hash), Some(fingerprint)) - } else { - (None, None) - }; + // 2. The profile fields, with avatar hashes computed from any raw bytes. + let fields = profile_fields(input)?; - // 3. Build the document property map. - let mut properties = std::collections::BTreeMap::new(); - if let Some(ref name) = input.display_name { - properties.insert("displayName".to_string(), Value::Text(name.clone())); - } - if let Some(ref msg) = input.public_message { - properties.insert("publicMessage".to_string(), Value::Text(msg.clone())); - } - if let Some(ref url) = input.avatar_url { - properties.insert("avatarUrl".to_string(), Value::Text(url.clone())); - } - if let Some(hash) = avatar_hash { - properties.insert("avatarHash".to_string(), Value::Bytes32(hash)); - } - if let Some(fp) = avatar_fingerprint { - properties.insert("avatarFingerprint".to_string(), Value::Bytes(fp.to_vec())); - } - - // 4. Look up identity + signing key. The identity_index is not + // 3. Look up identity + signing key. The identity_index is not // needed here — the signer is supplied externally. let signing_key = { let wm = self.wallet_manager.read().await; @@ -205,53 +180,18 @@ impl DashPayView<'_, B> { })? }; - let stub_document = Document::V0(DocumentV0 { - contract_version: None, - id: Identifier::from([0u8; 32]), - owner_id: *identity_id, - properties, - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - moderated_at: None, - moderated_by: None, - }); - - let profile_document_type = dashpay_contract - .document_type_for_name("profile") - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to get profile document type: {e}" - )) - })? - .to_owned_document_type(); - - let _result_doc = self - .sdk_writer - .put_document(super::sdk_writer::PutDocumentParams { - document: stub_document, - document_type: profile_document_type, - signing_public_key: signing_key, - signer: signer as &(dyn Signer + Send + Sync), - }) - .await?; - - let profile = crate::wallet::identity::DashPayProfile { - display_name: input.display_name, - bio: input.public_message.clone(), - avatar_url: input.avatar_url, - avatar_hash, - avatar_fingerprint, - public_message: input.public_message, - }; + let created = self + .sdk + .create_profile( + &dashpay_contract, + *identity_id, + fields, + &signing_key, + signer, + ) + .await + .map_err(PlatformWalletError::Sdk)?; + let profile = profile_from_properties(created.properties()); { let mut wm = self.wallet_manager.write().await; @@ -280,62 +220,16 @@ impl DashPayView<'_, B> { S: Signer + Send + Sync, { use dpp::data_contract::accessors::v0::DataContractV0Getters; - use dpp::document::Document; - use dpp::document::DocumentV0; - use dpp::document::INITIAL_REVISION; // 1. The DashPay contract (process-wide cache). let dashpay_contract = super::dashpay_contract()?; - // 2. Fetch existing profile document for ID + revision + its - // current property map (seed for the read-modify-write merge). - let (existing_doc_id, current_revision, existing_properties) = { - use dash_sdk::platform::FetchMany; - - let query = single_profile_query(&dashpay_contract, identity_id); - - let docs = Document::fetch_many(&self.sdk, query) - .await - .map_err(PlatformWalletError::Sdk)?; - - match docs.into_values().next() { - Some(Some(doc)) => { - let id = doc.id(); - let rev = doc.revision().unwrap_or(INITIAL_REVISION); - (id, rev, doc.properties().clone()) - } - _ => { - return Err(PlatformWalletError::InvalidIdentityData( - "No existing profile document found to update".to_string(), - )); - } - } - }; - - // 3. Compute avatar hashes only when new bytes are provided. - // Without new bytes the existing avatar fields are retained by - // the read-modify-write merge below (seeded from the on-platform - // document), so no local-cache fallback is needed. - let (avatar_hash, avatar_fingerprint) = if let Some(ref bytes) = input.avatar_bytes { - let hash = crate::wallet::identity::calculate_avatar_hash(bytes); - let fingerprint = crate::wallet::identity::calculate_dhash_fingerprint(bytes) - .map_err(PlatformWalletError::InvalidIdentityData)?; - (Some(hash), Some(fingerprint)) - } else { - (None, None) - }; - - // 4. Read-modify-write: seed from the existing document's - // properties so a partial update preserves sibling fields, - // then overlay only the caller-provided fields. - let properties = - merge_profile_properties(existing_properties, &input, avatar_hash, avatar_fingerprint); - - // The returned profile overwrites the local cache, so it must - // reflect the merged on-platform state, not the partial input. - let returned_profile = profile_from_properties(&properties); + // 2. The profile fields; avatar hashes are computed only when new + // bytes are provided. The SDK merges them onto the on-platform + // document, so unset fields (avatar included) are retained. + let fields = profile_fields(input)?; - // 5. Look up signing key. + // 3. Look up signing key. let signing_key = { let wm = self.wallet_manager.read().await; let info = wm @@ -358,46 +252,20 @@ impl DashPayView<'_, B> { })? }; - let updated_document = Document::V0(DocumentV0 { - contract_version: None, - id: existing_doc_id, - owner_id: *identity_id, - properties, - revision: Some(current_revision + 1), - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - moderated_at: None, - moderated_by: None, - }); - - let profile_document_type = dashpay_contract - .document_type_for_name("profile") - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to get profile document type: {e}" - )) - })? - .to_owned_document_type(); - - let _result_doc = self - .sdk_writer - .put_document(super::sdk_writer::PutDocumentParams { - document: updated_document, - document_type: profile_document_type, - signing_public_key: signing_key, - signer: signer as &(dyn Signer + Send + Sync), - }) - .await?; - - let profile = returned_profile; + // The returned profile overwrites the local cache, so it must + // reflect the merged on-platform state, not the partial input. + let merged = self + .sdk + .update_profile( + &dashpay_contract, + *identity_id, + fields, + &signing_key, + signer, + ) + .await + .map_err(PlatformWalletError::Sdk)?; + let profile = profile_from_properties(merged.properties()); { let mut wm = self.wallet_manager.write().await; @@ -413,41 +281,29 @@ impl DashPayView<'_, B> { } // --------------------------------------------------------------------------- -// Property-map helpers (read-modify-write merge + parse) +// Profile-field helpers // --------------------------------------------------------------------------- -/// Read-modify-write merge of a [`ProfileUpdate`] onto the property map of -/// the existing on-platform profile document. -/// -/// A profile update is **partial**: only the fields the caller set change. -/// Seeding from `existing` and overlaying just the provided fields -/// preserves sibling fields (publicMessage, avatarUrl, …) that a -/// fresh-map build would silently drop — the on-platform data loss this -/// guards against. Avatar hash/fingerprint are overlaid only when the -/// caller supplied new avatar bytes (`avatar_hash`/`avatar_fingerprint` -/// are `Some`); otherwise the existing avatar fields are retained. -fn merge_profile_properties( - mut existing: std::collections::BTreeMap, - input: &crate::wallet::identity::ProfileUpdate, - avatar_hash: Option<[u8; 32]>, - avatar_fingerprint: Option<[u8; 8]>, -) -> std::collections::BTreeMap { - if let Some(name) = &input.display_name { - existing.insert("displayName".to_string(), Value::Text(name.clone())); - } - if let Some(msg) = &input.public_message { - existing.insert("publicMessage".to_string(), Value::Text(msg.clone())); - } - if let Some(url) = &input.avatar_url { - existing.insert("avatarUrl".to_string(), Value::Text(url.clone())); - } - if let Some(hash) = avatar_hash { - existing.insert("avatarHash".to_string(), Value::Bytes32(hash)); - } - if let Some(fp) = avatar_fingerprint { - existing.insert("avatarFingerprint".to_string(), Value::Bytes(fp.to_vec())); - } - existing +/// The SDK profile fields for a [`ProfileUpdate`]. The avatar hash and dHash +/// fingerprint are computed only when new avatar bytes are provided. +fn profile_fields( + input: crate::wallet::identity::ProfileUpdate, +) -> Result { + let (avatar_hash, avatar_fingerprint) = if let Some(bytes) = &input.avatar_bytes { + let hash = crate::wallet::identity::calculate_avatar_hash(bytes); + let fingerprint = crate::wallet::identity::calculate_dhash_fingerprint(bytes) + .map_err(PlatformWalletError::InvalidIdentityData)?; + (Some(hash), Some(fingerprint)) + } else { + (None, None) + }; + Ok(ProfileFields { + display_name: input.display_name, + public_message: input.public_message, + avatar_url: input.avatar_url, + avatar_hash, + avatar_fingerprint, + }) } /// Parse a profile document's property map into a [`DashPayProfile`]. @@ -490,8 +346,6 @@ fn profile_from_properties( /// Max length of a DashPay `avatarUrl` (DIP-15). Longer is rejected. const MAX_AVATAR_URL_LEN: usize = 2048; -/// Platform `In`-clause cardinality cap; also the profile-fetch chunk size. -const CONTACT_PROFILE_IN_CAP: usize = 100; /// Re-fetch / re-check window for a cached contact profile. A present profile /// is refreshed and a confirmed-absent one re-checked at most once per window, /// bounding sync cost without the (unprovable-as-a-batch) `$updatedAt` @@ -625,7 +479,7 @@ impl DashPayView<'_, B> { let mut results: Vec<(Identifier, OwnerContactProfiles)> = Vec::new(); for (owner_id, to_fetch) in plan { let mut owner_results: OwnerContactProfiles = Vec::new(); - for chunk in to_fetch.chunks(CONTACT_PROFILE_IN_CAP) { + for chunk in to_fetch.chunks(MAX_PROFILE_OWNERS_PER_QUERY) { match self .fetch_contact_profiles_chunk(&dashpay_contract, chunk) .await @@ -693,33 +547,24 @@ impl DashPayView<'_, B> { Ok(written) } - /// Run one `$ownerId In [chunk]` profile query, returning the present - /// profiles keyed by owner id (absent ids are simply missing). The - /// `profile` `ownerId` index is unique, so the set lookup needs no - /// pagination (≤1 profile per owner). The query is built by - /// [`contact_profiles_chunk_query`]. + /// Fetch one chunk of profiles through [`Sdk::get_profiles`](dash_sdk::Sdk::get_profiles), + /// returning the present profiles keyed by owner id (absent ids are simply + /// missing). The `profile` `ownerId` index is unique, so the set lookup + /// needs no pagination (≤1 profile per owner). async fn fetch_contact_profiles_chunk( &self, dashpay_contract: &Arc, chunk: &[Identifier], ) -> Result>, PlatformWalletError> { - use dash_sdk::platform::FetchMany; - use dpp::document::Document; - - if chunk.is_empty() { - return Ok(Default::default()); - } - let query = contact_profiles_chunk_query(dashpay_contract, chunk); - - let docs = Document::fetch_many(&self.sdk, query) + let docs = self + .sdk + .get_profiles(dashpay_contract, chunk) .await .map_err(PlatformWalletError::Sdk)?; let mut out = std::collections::BTreeMap::new(); - for (_doc_id, maybe_doc) in docs { - let Some(doc) = maybe_doc else { continue }; - let owner = doc.owner_id(); + for (owner, doc) in docs { let mut profile = profile_from_properties(doc.properties()); // Drop an untrusted avatar URL rather than caching it. if profile @@ -739,82 +584,10 @@ impl DashPayView<'_, B> { } } -/// Build the single-owner `profile` fetch query WHERE `$ownerId = identity_id` -/// (`limit 1` — profile is unique per owner). Used by the external-signer -/// update's read-modify-write seed to fetch the current document's -/// id/revision/properties. -fn single_profile_query( - dashpay_contract: &Arc, - identity_id: &Identifier, -) -> dash_sdk::platform::DocumentQuery { - use dash_sdk::drive::query::{WhereClause, WhereOperator}; - use dpp::platform_value::platform_value; - - dash_sdk::platform::DocumentQuery { - select: dash_sdk::drive::query::SelectProjection::documents(), - data_contract: Arc::clone(dashpay_contract), - document_type_name: "profile".to_string(), - where_clauses: vec![WhereClause { - field: "$ownerId".to_string(), - operator: WhereOperator::Equal, - value: platform_value!(identity_id), - }], - time_range_clauses: vec![], - integer_range_clauses: vec![], - sub_queries: vec![], - group_by: vec![], - having: vec![], - order_by_clauses: vec![], - limit: 1, - offset: None, - start: None, - } -} - -/// Build the `$ownerId In [chunk]` profile fetch query for one chunk. -/// -/// `In` is a range operator, so DAPI requires a matching `orderBy` on the -/// range field or it rejects the query with "missing order by for range". -/// The `$ownerId` index is unique, so ordering does not change the result -/// set (≤1 profile per owner) — the `orderBy` is only there to satisfy that -/// range-orderBy rule. -fn contact_profiles_chunk_query( - dashpay_contract: &Arc, - chunk: &[Identifier], -) -> dash_sdk::platform::DocumentQuery { - use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; - use dpp::platform_value::{platform_value, Value}; - - let in_values = Value::Array(chunk.iter().map(|id| platform_value!(id)).collect()); - dash_sdk::platform::DocumentQuery { - select: dash_sdk::drive::query::SelectProjection::documents(), - data_contract: Arc::clone(dashpay_contract), - document_type_name: "profile".to_string(), - where_clauses: vec![WhereClause { - field: "$ownerId".to_string(), - operator: WhereOperator::In, - value: in_values, - }], - time_range_clauses: vec![], - integer_range_clauses: vec![], - sub_queries: vec![], - group_by: vec![], - having: vec![], - order_by_clauses: vec![OrderClause { - field: "$ownerId".to_string(), - ascending: true, - }], - limit: CONTACT_PROFILE_IN_CAP as u32, - offset: None, - start: None, - } -} - #[cfg(test)] mod tests { use super::super::signing_key::tests::KeyFilter; use super::*; - use crate::wallet::identity::ProfileUpdate; use std::collections::BTreeMap; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -913,102 +686,31 @@ mod tests { assert_eq!(pick(&identity), identity.public_keys().get(&1)); } - fn existing_full() -> BTreeMap { - let mut m = BTreeMap::new(); - m.insert("displayName".to_string(), Value::Text("Alice".into())); - m.insert( - "publicMessage".to_string(), - Value::Text("hello world".into()), - ); - m.insert( + /// The parsed profile reflects every field of a full property map; the + /// `publicMessage` is both the bio and the public message, and an + /// `avatarHash` stored as the sized `Bytes32` round-trips. + #[test] + fn profile_from_properties_reads_every_field() { + let mut props = BTreeMap::new(); + props.insert("displayName".to_string(), Value::Text("Alice".into())); + props.insert("publicMessage".to_string(), Value::Text("hello".into())); + props.insert( "avatarUrl".to_string(), Value::Text("https://x/a.png".into()), ); - m.insert("avatarHash".to_string(), Value::Bytes32([7u8; 32])); - m.insert( + props.insert("avatarHash".to_string(), Value::Bytes32([7u8; 32])); + props.insert( "avatarFingerprint".to_string(), Value::Bytes(vec![1, 2, 3, 4, 5, 6, 7, 8]), ); - m - } - - /// A partial update (only `displayName`) must NOT wipe sibling fields. - /// Contrasts the fixed read-modify-write (seed from existing) against - /// the old fresh-map build (empty seed), which dropped them — so the - /// test would fail against the pre-fix behavior. - #[test] - fn partial_update_preserves_sibling_fields() { - let input = ProfileUpdate { - display_name: Some("Alice 2".to_string()), - ..Default::default() - }; - - // Fixed: seed from the existing on-platform properties. - let merged = merge_profile_properties(existing_full(), &input, None, None); - assert_eq!( - merged.get("displayName").and_then(|v| v.as_str()), - Some("Alice 2") - ); - assert_eq!( - merged.get("publicMessage").and_then(|v| v.as_str()), - Some("hello world"), - ); - assert_eq!( - merged.get("avatarUrl").and_then(|v| v.as_str()), - Some("https://x/a.png"), - ); - assert!(merged.contains_key("avatarHash")); - assert!(merged.contains_key("avatarFingerprint")); - - // The old behavior — building a fresh/empty map — is exactly what - // caused the data loss: the same overlay drops every field the - // caller didn't set. - let buggy = merge_profile_properties(BTreeMap::new(), &input, None, None); - assert!( - !buggy.contains_key("publicMessage"), - "regression guard: a fresh/empty seed wipes sibling fields" - ); - assert!(!buggy.contains_key("avatarUrl")); - } - /// Avatar fields are overlaid only when new bytes are supplied; - /// otherwise the existing avatar is retained through the merge. - #[test] - fn avatar_overlaid_only_when_new_bytes_present() { - let input = ProfileUpdate { - display_name: Some("x".into()), - ..Default::default() - }; - - // No new avatar bytes => existing avatar retained. - let merged = merge_profile_properties(existing_full(), &input, None, None); - let prof = profile_from_properties(&merged); + let prof = profile_from_properties(&props); + assert_eq!(prof.display_name.as_deref(), Some("Alice")); + assert_eq!(prof.public_message.as_deref(), Some("hello")); + assert_eq!(prof.bio.as_deref(), Some("hello")); + assert_eq!(prof.avatar_url.as_deref(), Some("https://x/a.png")); assert_eq!(prof.avatar_hash, Some([7u8; 32])); assert_eq!(prof.avatar_fingerprint, Some([1, 2, 3, 4, 5, 6, 7, 8])); - - // New avatar bytes => overlaid. - let merged2 = - merge_profile_properties(existing_full(), &input, Some([9u8; 32]), Some([9u8; 8])); - let prof2 = profile_from_properties(&merged2); - assert_eq!(prof2.avatar_hash, Some([9u8; 32])); - assert_eq!(prof2.avatar_fingerprint, Some([9u8; 8])); - } - - /// The returned profile (which overwrites the local cache) reflects - /// the merged state, not the partial input — so a partial update does - /// not wipe the local mirror either. - #[test] - fn returned_profile_reflects_merge_not_input() { - let input = ProfileUpdate { - display_name: Some("Alice 2".into()), - ..Default::default() - }; - let merged = merge_profile_properties(existing_full(), &input, None, None); - let prof = profile_from_properties(&merged); - assert_eq!(prof.display_name.as_deref(), Some("Alice 2")); - assert_eq!(prof.public_message.as_deref(), Some("hello world")); - assert_eq!(prof.bio.as_deref(), Some("hello world")); - assert_eq!(prof.avatar_url.as_deref(), Some("https://x/a.png")); } // --- contact-profile sync helpers --- @@ -1099,36 +801,4 @@ mod tests { assert!(apply_fetched_profile(&mut cache, id, None, 400)); assert!(cache[&id].profile.is_none()); } - - /// DAPI rejects any query that uses a range where-operator without a - /// matching `orderBy` on the range field ("missing order by for range"). - /// The profile chunk query filters by `$ownerId In [...]` — a range op — - /// so every range clause it builds must carry an `orderBy` on its field. - /// Guarded against vacuous truth: the query must actually contain a range - /// clause, otherwise the invariant would pass trivially. - #[test] - fn contact_profiles_chunk_query_orders_by_every_range_field() { - let contract = crate::wallet::identity::network::dashpay_contract() - .expect("DashPay system contract loads"); - let chunk = vec![Identifier::new([1u8; 32]), Identifier::new([2u8; 32])]; - - let query = contact_profiles_chunk_query(&contract, &chunk); - - // Non-vacuous: there is at least one range where-clause to satisfy. - assert!( - query.where_clauses.iter().any(|wc| wc.operator.is_range()), - "expected a range where-clause (e.g. $ownerId In [...])" - ); - - for wc in &query.where_clauses { - if wc.operator.is_range() { - assert!( - query.order_by_clauses.iter().any(|oc| oc.field == wc.field), - "range clause on `{}` has no matching orderBy; DAPI rejects \ - this with \"missing order by for range\"", - wc.field - ); - } - } - } } From 91f3e860d32b490e86e1af884cb37c224bf3ba7f Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 00:30:31 -0500 Subject: [PATCH 06/20] refactor(sdk): move asset-lock submission retry and rejection matchers into rs-sdk The DashPay invitation claim is moving into rs-sdk, and its submission depends on three helpers that so far lived in rs-platform-wallet: the ChainLock-height (consensus 10506) retry that bumps user_fee_increase, and the stale-islock and consumed-outpoint rejection matchers. Move them to dash_sdk::platform::transition::asset_lock_submission with their tests, and add Error::consensus_error, the consensus-verdict extraction the matchers share. rs-platform-wallet re-exports the moved functions from error.rs and orchestration.rs, so its registration, top-up and funding paths and its public API are unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/Cargo.toml | 4 +- packages/rs-platform-wallet/src/error.rs | 211 +------- .../src/wallet/asset_lock/orchestration.rs | 289 +---------- packages/rs-sdk/Cargo.toml | 2 +- packages/rs-sdk/src/error.rs | 14 + packages/rs-sdk/src/platform/transition.rs | 1 + .../transition/asset_lock_submission.rs | 472 ++++++++++++++++++ .../src/platform/transition/top_up_address.rs | 4 +- 8 files changed, 508 insertions(+), 489 deletions(-) create mode 100644 packages/rs-sdk/src/platform/transition/asset_lock_submission.rs diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 5d62f66db0e..427c629064f 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -117,8 +117,8 @@ rusqlite = "0.38" static_assertions = "1.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # `test-util` brings `tokio::time::pause` / `tokio::test(start_paused = true)` -# so the time-driven retry logic in `submit_with_cl_height_retry` can be -# tested deterministically without sleeping wall-clock seconds. +# so time-driven manager, startup and SPV runtime logic can be tested +# deterministically without sleeping wall-clock seconds. tokio = { version = "1", features = ["sync", "rt", "time", "macros", "test-util"] } # Re-enable the SDK with mocks feature for test-only mock builders; # the non-test build keeps the leaner default-feature SDK above. diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8ba719674d0..a202ef4a1f8 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -9,6 +9,11 @@ use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundin use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::Network; +pub use dash_sdk::platform::transition::asset_lock_submission::{ + as_asset_lock_proof_cl_height_too_low, is_asset_lock_already_consumed, + is_instant_lock_proof_invalid, +}; + /// Errors that can occur in platform wallet operations #[derive(Debug, thiserror::Error)] pub enum PlatformWalletError { @@ -1061,30 +1066,13 @@ impl PlatformWalletError { pub fn consensus_error(&self) -> Option<&ConsensusError> { match self { Self::Sdk(source) | Self::TokenOperationFailed { source, .. } => { - consensus_error_of(source) + source.consensus_error() } _ => None, } } } -/// Check whether an SDK error indicates that an InstantSend lock proof was -/// rejected by Platform (e.g. the IS lock has expired). -/// -/// This matches the `InvalidInstantAssetLockProofSignatureError` consensus -/// error returned by Drive when the instant lock signature cannot be verified -/// (typically because the quorum that signed it has rotated out). -pub fn is_instant_lock_proof_invalid(error: &dash_sdk::Error) -> bool { - use dpp::consensus::basic::BasicError; - - matches!( - consensus_error_of(error), - Some(ConsensusError::BasicError( - BasicError::InvalidInstantAssetLockProofSignatureError(_), - )) - ) -} - /// Check whether an SDK error is Platform rejecting a ChainLock asset-lock /// proof because the funding transaction is not in a block at or below the /// proof's height (`InvalidAssetLockProofTransactionHeightError`) — the proof @@ -1093,7 +1081,7 @@ pub fn is_asset_lock_proof_transaction_height_invalid(error: &dash_sdk::Error) - use dpp::consensus::basic::BasicError; matches!( - consensus_error_of(error), + error.consensus_error(), Some(ConsensusError::BasicError( BasicError::InvalidAssetLockProofTransactionHeightError(_), )) @@ -1117,61 +1105,6 @@ pub fn is_instant_lock_timeout(error: &PlatformWalletError) -> bool { matches!(error, PlatformWalletError::FinalityTimeout(_)) } -/// Extract the `InvalidAssetLockProofCoreChainHeightError` (DPP -/// consensus code 10506) from an SDK error if Platform rejected a -/// ChainLock asset-lock proof because Platform's -/// `last_committed_core_height` is still behind the proof's -/// `core_chain_locked_height`. -/// -/// Returns `Some(&error)` whenever the rejection matches, exposing -/// `proof_core_chain_locked_height()` (what the wallet claimed) and -/// `current_core_chain_locked_height()` (Platform's currently observed -/// tip). The latter is what the wallet can log to attribute the lag -/// to: small means routine race against Platform's -/// `create-empty-blocks-interval` (3m on mainnet); large or stuck -/// means the DAPI node we hit is genuinely behind / misbehaving. -/// -/// Returns `None` for everything else. The check is stateless and -/// re-evaluated on every CheckTx, so a resubmit after Platform -/// catches up will pass — but Tenderdash's mempool caches rejected-tx -/// hashes for ~24h on mainnet/testnet (`keep-invalid-txs-in-cache = -/// true` in dashmate's tenderdash template), so the resubmit must -/// carry a *different* signable-bytes hash to bypass the cache. The -/// submission layer handles that by bumping -/// `PutSettings::user_fee_increase` before re-issuing. -/// -/// Companion to [`is_instant_lock_proof_invalid`]; both feed the -/// CL-proof retry path in the identity registration / top-up flow. -/// -/// **Coverage caveat:** only inspects the two `dash_sdk::Error` -/// variants that today wrap consensus errors — -/// `StateTransitionBroadcastError` (from `broadcast_and_wait`) and -/// `Protocol(ProtocolError::ConsensusError)` (from validation). If a -/// future SDK version surfaces the same `InvalidAssetLockProofCoreChainHeightError` -/// through a different variant (e.g. wrapped in a transport-layer -/// error type), the retry helper silently falls through to the "Sdk" -/// passthrough. Re-audit this matcher whenever `dash_sdk::Error` -/// gains new variants that can carry consensus errors. -pub fn as_asset_lock_proof_cl_height_too_low( - error: &dash_sdk::Error, -) -> Option<&dpp::consensus::basic::identity::InvalidAssetLockProofCoreChainHeightError> { - use dpp::consensus::basic::BasicError; - - let consensus_error = match error { - dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => { - broadcast_err.cause.as_ref() - } - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), - _ => None, - }; - match consensus_error { - Some(ConsensusError::BasicError( - BasicError::InvalidAssetLockProofCoreChainHeightError(e), - )) => Some(e), - _ => None, - } -} - /// Extract the `AddressInvalidNonceError` (DPP consensus code 40603) when /// Platform rejected an address-funds transition on a stale nonce, exposing /// `address()`, `provided_nonce()`, and `expected_nonce()` so the caller can @@ -1225,48 +1158,6 @@ pub fn promote_address_nonce_error_or_sdk(error: dash_sdk::Error) -> PlatformWal promote_address_nonce_error(&error).unwrap_or(PlatformWalletError::Sdk(error)) } -/// Extract the consensus verdict from the `dash_sdk::Error` shapes that can -/// carry one — `StateTransitionBroadcastError` (wait-stream), -/// `Protocol(ConsensusError)` (CheckTx), and the dapi-client's -/// exhausted-retry envelope it recurses into. Shared by the typed-promotion -/// matchers below; the same coverage caveat as -/// [`as_asset_lock_proof_cl_height_too_low`] applies (re-audit when -/// `dash_sdk::Error` gains consensus-carrying variants). -fn consensus_error_of(error: &dash_sdk::Error) -> Option<&ConsensusError> { - match error { - dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => { - broadcast_err.cause.as_ref() - } - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), - dash_sdk::Error::NoAvailableAddressesToRetry(inner) => consensus_error_of(inner), - _ => None, - } -} - -/// Whether Platform rejected a transition because the exact asset-lock -/// outpoint it submitted has already been consumed. -/// -/// Matches the structured consensus error carried by both CheckTx -/// (`Protocol(ConsensusError)`) and wait-stream -/// (`StateTransitionBroadcastError`) failures. The outpoint comparison is -/// deliberate: callers may only recognize a report for the tracked lock they -/// actually submitted, never an unrelated outpoint mentioned by a malformed -/// error. This signal alone does not authenticate terminal consumption. -pub fn is_asset_lock_already_consumed( - error: &dash_sdk::Error, - out_point: &dashcore::OutPoint, -) -> bool { - use dpp::consensus::basic::BasicError; - - matches!( - consensus_error_of(error), - Some(ConsensusError::BasicError( - BasicError::IdentityAssetLockTransactionOutPointAlreadyConsumedError(e), - )) if e.transaction_id() == out_point.txid - && e.output_index() == out_point.vout as usize - ) -} - /// Promote a document-trade consensus rejection to its typed /// [`PlatformWalletError`] so callers get structured data instead of a /// stringified verdict: @@ -1283,7 +1174,7 @@ pub fn is_asset_lock_already_consumed( pub fn promote_document_trade_error(error: &dash_sdk::Error) -> Option { use dpp::consensus::state::state_error::StateError; - match consensus_error_of(error)? { + match error.consensus_error()? { ConsensusError::StateError(StateError::DocumentNotForSaleError(e)) => { Some(PlatformWalletError::DocumentNotForSale { document_id: *e.document_id(), @@ -1315,7 +1206,7 @@ pub fn promote_identity_insufficient_balance( ) -> Option { use dpp::consensus::state::state_error::StateError; - match consensus_error_of(error)? { + match error.consensus_error()? { ConsensusError::StateError(StateError::IdentityInsufficientBalanceError(e)) => { Some(PlatformWalletError::InsufficientIdentityCredits { identity_id: *e.identity_id(), @@ -1756,90 +1647,6 @@ mod address_nonce_tests { } } -#[cfg(test)] -mod asset_lock_already_consumed_tests { - use super::*; - use dash_sdk::error::StateTransitionBroadcastError; - use dashcore::hashes::Hash; - use dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError; - use dpp::consensus::basic::UnsupportedProtocolVersionError; - - fn out_point() -> dashcore::OutPoint { - dashcore::OutPoint::new(dashcore::Txid::all_zeros(), 7) - } - - fn consensus_error() -> dpp::consensus::ConsensusError { - let out_point = out_point(); - IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( - out_point.txid, - out_point.vout as usize, - ) - .into() - } - - fn unrelated_consensus_error() -> dpp::consensus::ConsensusError { - UnsupportedProtocolVersionError::new(2, 1).into() - } - - #[test] - fn recognizes_protocol_consensus_error_for_exact_outpoint() { - let error = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( - consensus_error(), - ))); - - assert!(is_asset_lock_already_consumed(&error, &out_point())); - } - - #[test] - fn recognizes_broadcast_consensus_error_for_exact_outpoint() { - let error = dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { - code: 10504, - message: "asset lock already consumed".to_string(), - cause: Some(consensus_error()), - }); - - assert!(is_asset_lock_already_consumed(&error, &out_point())); - } - - #[test] - fn ignores_unrelated_errors_and_different_outpoints() { - let error = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( - consensus_error(), - ))); - let different_out_point = dashcore::OutPoint::new(dashcore::Txid::all_zeros(), 8); - - assert!(!is_asset_lock_already_consumed( - &error, - &different_out_point - )); - assert!(!is_asset_lock_already_consumed( - &dash_sdk::Error::Generic("boom".to_string()), - &out_point() - )); - - let unrelated_protocol = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError( - Box::new(unrelated_consensus_error()), - )); - assert!(!is_asset_lock_already_consumed( - &unrelated_protocol, - &out_point() - )); - - let unrelated_broadcast = - dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { - code: 10504, - // Deliberately resembles the target message: matching must - // depend on the structured cause, never this display text. - message: "asset lock output already completely used".to_string(), - cause: Some(unrelated_consensus_error()), - }); - assert!(!is_asset_lock_already_consumed( - &unrelated_broadcast, - &out_point() - )); - } -} - #[cfg(test)] mod identity_insufficient_balance_tests { use super::*; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index 005264ee0b3..771aab47cdf 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -12,10 +12,11 @@ //! //! Two pieces here: //! -//! - [`submit_with_cl_height_retry`] — retry-on-10506 wrapper that -//! bumps `user_fee_increase` between attempts so Tenderdash's -//! invalid-tx hash cache (24h on mainnet/testnet) can't silently -//! drop resubmits. +//! - [`submit_with_cl_height_retry`] — re-exported from +//! `dash_sdk::platform::transition::asset_lock_submission`: the +//! retry-on-10506 wrapper that bumps `user_fee_increase` between +//! attempts so Tenderdash's invalid-tx hash cache (24h on +//! mainnet/testnet) can't silently drop resubmits. //! - [`AssetLockManager::resolve_funding_with_is_timeout_fallback`] — //! maps an [`AssetLockFunding`] choice to a [`FundingResolution`] //! that the caller can drive into an IS→CL retry when the IS-lock @@ -36,12 +37,10 @@ use dpp::prelude::AssetLockProof; use key_wallet::bip32::DerivationPath; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; -use dash_sdk::platform::transition::put_settings::PutSettings; +pub(crate) use dash_sdk::platform::transition::asset_lock_submission::submit_with_cl_height_retry; use crate::broadcaster::TransactionBroadcaster; -use crate::error::{ - as_asset_lock_proof_cl_height_too_low, is_asset_lock_already_consumed, PlatformWalletError, -}; +use crate::error::{is_asset_lock_already_consumed, PlatformWalletError}; use crate::wallet::asset_lock::manager::AssetLockManager; // --------------------------------------------------------------------------- @@ -115,23 +114,6 @@ pub(crate) const RECONCILIATION_CHAIN_LOCK_TIMEOUT: Duration = Duration::from_se /// immediate give-up would report. pub(crate) const UNCONFIRMED_BROADCAST_PROOF_TIMEOUT: Duration = Duration::from_secs(180); -/// Delay between retries when Platform rejected with CL-height-too-low. -/// Each retry bumps `PutSettings::user_fee_increase` so the ST hash -/// changes (Tenderdash caches rejected ST hashes for ~24h on -/// mainnet/testnet — `keep-invalid-txs-in-cache = true` in dashmate's -/// tenderdash template, hardcoded at -/// `packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot:355`). -pub(crate) const CL_HEIGHT_RETRY_DELAY: Duration = Duration::from_secs(15); - -/// Total time we'll keep retrying before surfacing the error. Sized to -/// cover Platform's `create-empty-blocks-interval` (3m on mainnet) -/// plus a 30s safety margin: if Platform hasn't observed the wallet's -/// ChainLock by then, the lag is no longer routine and we need -/// operator visibility instead of further silent retries. The -/// rejection error carries Platform's `current_core_chain_locked_height` -/// each round so logs name the laggard node's reported tip explicitly. -pub(crate) const CL_HEIGHT_RETRY_BUDGET: Duration = Duration::from_secs(210); - // --------------------------------------------------------------------------- // Funding choice // --------------------------------------------------------------------------- @@ -278,139 +260,6 @@ pub(crate) enum FundingResolution { }, } -// --------------------------------------------------------------------------- -// Retry helper -// --------------------------------------------------------------------------- - -/// Submit a state transition with automatic retry on -/// `InvalidAssetLockProofCoreChainHeightError` (consensus code 10506). -/// -/// Each retry bumps `settings.user_fee_increase` so the resubmitted ST -/// hashes differently — Tenderdash caches rejected ST hashes for ~24h -/// on mainnet/testnet (`keep-invalid-txs-in-cache = true`), so an -/// identical-bytes resubmit would be silently dropped before reaching -/// Platform's CheckTx. -/// -/// **Retry scope.** This wrapper retries ONLY consensus code 10506 -/// (CL-height-too-low). Every other `dash_sdk::Error` — including -/// transient gRPC `UNAVAILABLE`, DAPI 502/503, RST_STREAM, TLS -/// resets, DNS hiccups, mempool-full bounces — falls through -/// immediately on the first attempt. The rationale: the DAPI client -/// layer (`rs-dapi-client`) below the SDK already implements its own -/// per-request retry + endpoint rotation for transport-level -/// failures, so a second layer of generic retries here would -/// over-retry (or worse, retry an ST submission that the lower -/// layer already retried, against a different validator, leaving -/// two in-flight copies). 10506 is uniquely retried at THIS layer -/// because the fix requires a different `user_fee_increase` value — -/// the lower layer can't know that. -/// -/// If the underlying SDK starts surfacing a transient error class -/// that the DAPI client doesn't already retry, widen this match -/// rather than wrapping `submit_with_cl_height_retry` in a second -/// generic-retry loop at the caller. -/// -/// We don't pre-flight Platform's chain-lock tip — that's an unproven -/// self-report and a malicious DAPI node could stall us indefinitely. -/// Submit optimistically and react to Platform's deterministic CheckTx -/// rejection. The cryptographic finality guarantee on the wallet side -/// comes from the SPV-verified ChainLock BLS signature -/// (`info.core_wallet.metadata.last_applied_chain_lock`) that promoted -/// the asset-lock tx's record context to `InChainLockedBlock` before -/// we constructed the proof. -/// -/// **Trust model.** This function treats the 10506 response as -/// authoritative — there's no client-side cryptographic proof or -/// DAPI-quorum check on the consensus error. That trust boundary -/// lives one layer up: a node that fabricates rejections is a -/// malicious DAPI node, and the right defense is to stop submitting -/// to it (DAPI client rotation / blacklisting), not to engineer -/// around fabricated responses here. Bumping `user_fee_increase` in -/// response to a forged 10506 can grief a user (wasted credits, -/// slowed registration) but cannot extract value — identity fees -/// flow to Platform validators, not DAPI nodes — so the attack is -/// unprofitable. The bounded retry budget further caps the grief -/// impact: at most `CL_HEIGHT_RETRY_BUDGET / CL_HEIGHT_RETRY_DELAY` -/// bumps (~14 with the current 210s/15s pair) before the loop -/// surfaces the error. A proper fix would require cryptographically -/// verifiable consensus errors (a quorum signature on rejection, or -/// validator attestation) and is tracked as future work; doing it -/// in-place here would either re-implement DAPI client trust or -/// require an SDK API change neither of which belong in this PR. -/// -/// Non-CL-height errors are passed through unchanged. Every rejection -/// is logged with both the proof's claimed height and Platform's -/// currently observed Core tip so persistent lag (>3.5min) attributes -/// to the specific DAPI node we hit and not to a generic timeout. -/// -/// **Cancellation:** not cancellation-safe. If the caller drops the -/// returned future mid-sleep, the bumped `user_fee_increase` is lost -/// and any in-flight submission whose response we never consume -/// remains queued in Tenderdash's mempool until it commits or expires. -/// Callers wrapping this in `tokio::select!` with a short timeout -/// should be prepared to either retry (settings reset to original) -/// or accept that Platform may still execute the dropped attempt. -pub(crate) async fn submit_with_cl_height_retry( - mut settings: Option, - submit: F, -) -> Result -where - F: Fn(Option) -> Fut, - Fut: std::future::Future>, -{ - let started = tokio::time::Instant::now(); - let deadline = started + CL_HEIGHT_RETRY_BUDGET; - let mut attempt: u32 = 0; - loop { - attempt += 1; - match submit(settings).await { - Ok(r) => return Ok(r), - Err(e) => { - let Some(detail) = as_asset_lock_proof_cl_height_too_low(&e) else { - return Err(e); - }; - let elapsed = started.elapsed(); - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - tracing::error!( - "Platform rejected ChainLock proof: CL height too low \ - (proof claimed height={}, Platform tip={}, attempt {}, \ - elapsed {}s) — retry budget of {}s exhausted; surfacing \ - error. Platform's reported tip is stuck — likely a lagging \ - or misbehaving DAPI node.", - detail.proof_core_chain_locked_height(), - detail.current_core_chain_locked_height(), - attempt, - elapsed.as_secs(), - CL_HEIGHT_RETRY_BUDGET.as_secs(), - ); - return Err(e); - } - let sleep_for = remaining.min(CL_HEIGHT_RETRY_DELAY); - tracing::warn!( - "Platform rejected ChainLock proof: CL height too low \ - (proof claimed height={}, Platform tip={}, attempt {}, \ - elapsed {}s); bumping user_fee_increase and waiting {}s \ - before retry", - detail.proof_core_chain_locked_height(), - detail.current_core_chain_locked_height(), - attempt, - elapsed.as_secs(), - sleep_for.as_secs(), - ); - settings = Some(bump_user_fee_increase(settings.unwrap_or_default())); - tokio::time::sleep(sleep_for).await; - } - } - } -} - -/// Bump `user_fee_increase` by 1 (saturating at `u16::MAX`). -fn bump_user_fee_increase(mut settings: PutSettings) -> PutSettings { - settings.user_fee_increase = Some(settings.user_fee_increase.unwrap_or(0).saturating_add(1)); - settings -} - // --------------------------------------------------------------------------- // Free helpers // --------------------------------------------------------------------------- @@ -880,130 +729,6 @@ mod tests { )); } - /// Fabricate the SDK-side 10506 error shape exactly as - /// `as_asset_lock_proof_cl_height_too_low` recognizes it - /// (`error.rs:223-242`). Both the matcher and the constructor are - /// pinned here so a future SDK refactor that changes the variant - /// path can't silently desynchronize the retry helper from its - /// test surface. - fn fabricate_cl_height_too_low_error() -> dash_sdk::Error { - use dpp::consensus::basic::identity::InvalidAssetLockProofCoreChainHeightError; - use dpp::consensus::basic::BasicError; - use dpp::consensus::ConsensusError; - - let consensus = - ConsensusError::BasicError(BasicError::InvalidAssetLockProofCoreChainHeightError( - InvalidAssetLockProofCoreChainHeightError::new( - /* proof_core_chain_locked_height */ 100, - /* current_core_chain_locked_height */ 99, - ), - )); - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(consensus))) - } - - /// Pins two load-bearing invariants of `submit_with_cl_height_retry`: - /// - /// 1. Every retry under repeated `InvalidAssetLockProofCoreChainHeightError` - /// (consensus 10506) receives a `PutSettings::user_fee_increase` - /// strictly greater than the previous attempt. The retry's purpose - /// is to bypass Tenderdash's 24h invalid-tx hash cache by changing - /// the ST signable bytes; if `user_fee_increase` weren't bumped, - /// every resubmit would hash identically and be silently dropped. - /// This invariant regressed silently once in the earlier - /// swift-funding-with-asset-lock series — the test exists so it - /// can't regress quietly again. - /// - /// 2. After `CL_HEIGHT_RETRY_BUDGET` elapses without a non-10506 - /// outcome, the helper surfaces the original 10506 error rather - /// than looping forever or swallowing it. - /// - /// Driven by `#[tokio::test(start_paused = true)]` + manual - /// `tokio::time::advance` so the retry's `CL_HEIGHT_RETRY_DELAY` - /// sleeps fire instantly and total test wall time is sub-millisecond. - #[tokio::test(start_paused = true)] - async fn submit_with_cl_height_retry_bumps_user_fee_and_surfaces_after_budget() { - use dash_sdk::platform::transition::put_settings::PutSettings; - use std::sync::atomic::{AtomicU32, Ordering}; - use std::sync::Arc; - use tokio::sync::Mutex; - - // Capture each invocation's `user_fee_increase` (None on the - // first call, then Some(N) for each retry). Shared `Mutex` - // because the closure is `Fn` and each future is independent. - let captured: Arc>>> = Arc::new(Mutex::new(Vec::new())); - let call_count = Arc::new(AtomicU32::new(0)); - let captured_clone = captured.clone(); - let call_count_clone = call_count.clone(); - - // Stub `submit` closure: always returns 10506 so the retry loop - // exhausts its budget. The helper's return type is generic over - // `R`; pin `R = ()` for this test (we never reach the success - // path). - let submit = move |settings: Option| { - let captured = captured_clone.clone(); - let call_count = call_count_clone.clone(); - async move { - call_count.fetch_add(1, Ordering::SeqCst); - captured - .lock() - .await - .push(settings.and_then(|s| s.user_fee_increase)); - Err::<(), _>(fabricate_cl_height_too_low_error()) - } - }; - - let result = submit_with_cl_height_retry(None, submit).await; - - // Surfaced error must be the original 10506 — not a wrapper, not - // a "timeout" type, not None. - assert!( - result.is_err(), - "retry must surface the underlying error on budget exhaust" - ); - let surfaced_err = result.unwrap_err(); - assert!( - as_asset_lock_proof_cl_height_too_low(&surfaced_err).is_some(), - "surfaced error must still be the InvalidAssetLockProofCoreChainHeightError" - ); - - let captured = captured.lock().await; - let call_n = call_count.load(Ordering::SeqCst); - - // At least 2 attempts (initial + at least one retry); upper - // bound is `budget / delay` + 1 with a small slack for the - // boundary check. - let max_expected = - (CL_HEIGHT_RETRY_BUDGET.as_secs() / CL_HEIGHT_RETRY_DELAY.as_secs()) as u32 + 2; - assert!( - call_n >= 2 && call_n <= max_expected, - "expected 2..={max_expected} attempts (initial + retries up to budget), got {call_n}" - ); - assert_eq!( - captured.len() as u32, - call_n, - "every closure invocation should have recorded a fee value" - ); - - // First attempt: caller-supplied `None` settings → user_fee_increase = None. - assert_eq!( - captured[0], None, - "first attempt must use the caller-supplied `None` settings (no bump yet)" - ); - - // Subsequent attempts: strictly increasing `user_fee_increase`, - // starting from Some(1) and bumping by 1 each retry. The exact - // values are load-bearing: Tenderdash hashes the full ST bytes - // including this field, so consecutive identical values would - // hit the 24h invalid-tx cache. - for (i, val) in captured.iter().enumerate().skip(1) { - let expected = Some(i as u16); - assert_eq!( - *val, expected, - "attempt #{i} (1-indexed retry) must carry user_fee_increase = {expected:?}, got {val:?}" - ); - } - } - // ----------------------------------------------------------------- // Reconciliation promotion-failure regressions // diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 9a6d98b6622..6a063ba7ddf 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -63,7 +63,7 @@ platform-version = { path = "../rs-platform-version", features = [ rs-dapi-client = { path = "../rs-dapi-client" } drive-proof-verifier = { path = "../rs-drive-proof-verifier" } rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider" } -tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "test-util"] } base64 = { version = "0.22.1" } tracing-subscriber = { version = "0.3.22", features = ["env-filter", "registry"] } dpp = { path = "../rs-dpp", default-features = false, features = [ diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 7ba0d1597a0..b2afbb91f40 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -151,6 +151,20 @@ impl Error { _ => false, } } + + /// The consensus verdict this error carries, from the shapes that can + /// carry one: `StateTransitionBroadcastError` (wait-stream), + /// `Protocol(ConsensusError)` (CheckTx), and the dapi-client's + /// exhausted-retry envelope, which it recurses into. Re-audit when this + /// enum gains another consensus-carrying variant. + pub fn consensus_error(&self) -> Option<&ConsensusError> { + match self { + Self::StateTransitionBroadcastError(broadcast_err) => broadcast_err.cause.as_ref(), + Self::Protocol(ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + Self::NoAvailableAddressesToRetry(inner) => inner.consensus_error(), + _ => None, + } + } } /// A trusted source outage is not the responding node's fault. Preserve that diff --git a/packages/rs-sdk/src/platform/transition.rs b/packages/rs-sdk/src/platform/transition.rs index 8d9a015995c..3a99af47952 100644 --- a/packages/rs-sdk/src/platform/transition.rs +++ b/packages/rs-sdk/src/platform/transition.rs @@ -1,6 +1,7 @@ //! State transitions used to put changed objects to the Dash Platform. pub mod address_credit_withdrawal; pub(crate) mod address_inputs; +pub mod asset_lock_submission; /// Re-export the canonical address-input fetch + hard balance check so /// downstream crates (e.g. platform-wallet's shield path) reuse it /// instead of re-implementing the fetch-and-validate dance. diff --git a/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs b/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs new file mode 100644 index 00000000000..5641718364b --- /dev/null +++ b/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs @@ -0,0 +1,472 @@ +//! Submitting asset-lock-funded state transitions: the Platform rejections +//! a caller reacts to, and the retry on a ChainLock proof that is ahead of +//! Platform's view of Core. +//! +//! - [`is_instant_lock_proof_invalid`] — Platform rejected a (stale) islock; +//! the caller can resubmit a ChainLock proof for the same outpoint. +//! - [`is_asset_lock_already_consumed`] — the exact outpoint was already +//! spent on Platform. +//! - [`as_asset_lock_proof_cl_height_too_low`] — Platform's consensus 10506 +//! rejection (the proof's ChainLock is ahead of Platform's Core view). +//! - [`submit_with_cl_height_retry`] — retry-on-10506 wrapper that bumps +//! `user_fee_increase` between attempts so Tenderdash's invalid-tx hash +//! cache (24h on mainnet/testnet) can't silently drop resubmits. + +use std::time::Duration; + +use dpp::consensus::basic::identity::InvalidAssetLockProofCoreChainHeightError; +use dpp::consensus::basic::BasicError; +use dpp::consensus::ConsensusError; +use dpp::dashcore::OutPoint; +use dpp::ProtocolError; + +use super::put_settings::PutSettings; +use crate::Error; + +/// Delay between retries when Platform rejected with CL-height-too-low. +/// Each retry bumps `PutSettings::user_fee_increase` so the ST hash +/// changes (Tenderdash caches rejected ST hashes for ~24h on +/// mainnet/testnet — `keep-invalid-txs-in-cache = true` in dashmate's +/// tenderdash template, hardcoded at +/// `packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot:355`). +const CL_HEIGHT_RETRY_DELAY: Duration = Duration::from_secs(15); + +/// Total time we'll keep retrying before surfacing the error. Sized to +/// cover Platform's `create-empty-blocks-interval` (3m on mainnet) +/// plus a 30s safety margin: if Platform hasn't observed the wallet's +/// ChainLock by then, the lag is no longer routine and we need +/// operator visibility instead of further silent retries. The +/// rejection error carries Platform's `current_core_chain_locked_height` +/// each round so logs name the laggard node's reported tip explicitly. +const CL_HEIGHT_RETRY_BUDGET: Duration = Duration::from_secs(210); + +/// The most submissions one retry run makes: the initial attempt plus one per +/// `CL_HEIGHT_RETRY_DELAY` in the budget. This bounds the fee bumps even when +/// the elapsed-time clock stalls, as a wall clock moved backward does on wasm32. +const CL_HEIGHT_RETRY_MAX_ATTEMPTS: u32 = + (CL_HEIGHT_RETRY_BUDGET.as_secs() / CL_HEIGHT_RETRY_DELAY.as_secs()) as u32 + 1; + +/// Check whether an SDK error indicates that an InstantSend lock proof was +/// rejected by Platform (e.g. the IS lock has expired). +/// +/// This matches the `InvalidInstantAssetLockProofSignatureError` consensus +/// error returned by Drive when the instant lock signature cannot be verified +/// (typically because the quorum that signed it has rotated out). +pub fn is_instant_lock_proof_invalid(error: &Error) -> bool { + matches!( + error.consensus_error(), + Some(ConsensusError::BasicError( + BasicError::InvalidInstantAssetLockProofSignatureError(_), + )) + ) +} + +/// Whether Platform rejected a transition because the exact asset-lock +/// outpoint it submitted has already been consumed. +/// +/// Matches the structured consensus error carried by both CheckTx +/// (`Protocol(ConsensusError)`) and wait-stream +/// (`StateTransitionBroadcastError`) failures. The outpoint comparison is +/// deliberate: callers may only recognize a report for the tracked lock they +/// actually submitted, never an unrelated outpoint mentioned by a malformed +/// error. This signal alone does not authenticate terminal consumption. +pub fn is_asset_lock_already_consumed(error: &Error, out_point: &OutPoint) -> bool { + matches!( + error.consensus_error(), + Some(ConsensusError::BasicError( + BasicError::IdentityAssetLockTransactionOutPointAlreadyConsumedError(e), + )) if e.transaction_id() == out_point.txid + && e.output_index() == out_point.vout as usize + ) +} + +/// Extract the `InvalidAssetLockProofCoreChainHeightError` (DPP +/// consensus code 10506) from an SDK error if Platform rejected a +/// ChainLock asset-lock proof because Platform's +/// `last_committed_core_height` is still behind the proof's +/// `core_chain_locked_height`. +/// +/// Returns `Some(&error)` whenever the rejection matches, exposing +/// `proof_core_chain_locked_height()` (what the wallet claimed) and +/// `current_core_chain_locked_height()` (Platform's currently observed +/// tip). The latter is what the wallet can log to attribute the lag +/// to: small means routine race against Platform's +/// `create-empty-blocks-interval` (3m on mainnet); large or stuck +/// means the DAPI node we hit is genuinely behind / misbehaving. +/// +/// Returns `None` for everything else. The check is stateless and +/// re-evaluated on every CheckTx, so a resubmit after Platform +/// catches up will pass — but Tenderdash's mempool caches rejected-tx +/// hashes for ~24h on mainnet/testnet (`keep-invalid-txs-in-cache = +/// true` in dashmate's tenderdash template), so the resubmit must +/// carry a *different* signable-bytes hash to bypass the cache. The +/// submission layer handles that by bumping +/// `PutSettings::user_fee_increase` before re-issuing. +/// +/// Companion to [`is_instant_lock_proof_invalid`]; both feed the +/// CL-proof retry path in the identity registration / top-up flow. +/// +/// **Coverage caveat:** only inspects the two `Error` variants that today +/// wrap consensus errors — `StateTransitionBroadcastError` (from +/// `broadcast_and_wait`) and `Protocol(ProtocolError::ConsensusError)` +/// (from validation). If a future SDK version surfaces the same +/// `InvalidAssetLockProofCoreChainHeightError` through a different variant +/// (e.g. wrapped in a transport-layer error type), the retry helper +/// silently falls through to the passthrough. Re-audit this matcher +/// whenever `Error` gains new variants that can carry consensus errors. +pub fn as_asset_lock_proof_cl_height_too_low( + error: &Error, +) -> Option<&InvalidAssetLockProofCoreChainHeightError> { + let consensus_error = match error { + Error::StateTransitionBroadcastError(broadcast_err) => broadcast_err.cause.as_ref(), + Error::Protocol(ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + _ => None, + }; + match consensus_error { + Some(ConsensusError::BasicError( + BasicError::InvalidAssetLockProofCoreChainHeightError(e), + )) => Some(e), + _ => None, + } +} + +/// Submit a state transition with automatic retry on +/// `InvalidAssetLockProofCoreChainHeightError` (consensus code 10506). +/// +/// Each retry bumps `settings.user_fee_increase` so the resubmitted ST +/// hashes differently — Tenderdash caches rejected ST hashes for ~24h +/// on mainnet/testnet (`keep-invalid-txs-in-cache = true`), so an +/// identical-bytes resubmit would be silently dropped before reaching +/// Platform's CheckTx. +/// +/// **Retry scope.** This wrapper retries ONLY consensus code 10506 +/// (CL-height-too-low). Every other `Error` — including +/// transient gRPC `UNAVAILABLE`, DAPI 502/503, RST_STREAM, TLS +/// resets, DNS hiccups, mempool-full bounces — falls through +/// immediately on the first attempt. The rationale: the DAPI client +/// layer (`rs-dapi-client`) below the SDK already implements its own +/// per-request retry + endpoint rotation for transport-level +/// failures, so a second layer of generic retries here would +/// over-retry (or worse, retry an ST submission that the lower +/// layer already retried, against a different validator, leaving +/// two in-flight copies). 10506 is uniquely retried at THIS layer +/// because the fix requires a different `user_fee_increase` value — +/// the lower layer can't know that. +/// +/// If the underlying SDK starts surfacing a transient error class +/// that the DAPI client doesn't already retry, widen this match +/// rather than wrapping `submit_with_cl_height_retry` in a second +/// generic-retry loop at the caller. +/// +/// We don't pre-flight Platform's chain-lock tip — that's an unproven +/// self-report and a malicious DAPI node could stall us indefinitely. +/// Submit optimistically and react to Platform's deterministic CheckTx +/// rejection. The cryptographic finality guarantee on the wallet side +/// comes from the wallet's own verified ChainLock that promoted the +/// asset-lock tx to chain-locked before the proof was constructed. +/// +/// **Trust model.** This function treats the 10506 response as +/// authoritative — there's no client-side cryptographic proof or +/// DAPI-quorum check on the consensus error. That trust boundary +/// lives one layer up: a node that fabricates rejections is a +/// malicious DAPI node, and the right defense is to stop submitting +/// to it (DAPI client rotation / blacklisting), not to engineer +/// around fabricated responses here. Bumping `user_fee_increase` in +/// response to a forged 10506 can grief a user (wasted credits, +/// slowed registration) but cannot extract value — identity fees +/// flow to Platform validators, not DAPI nodes — so the attack is +/// unprofitable. The bounded retry budget further caps the grief +/// impact: at most `CL_HEIGHT_RETRY_BUDGET / CL_HEIGHT_RETRY_DELAY` +/// bumps (~14 with the current 210s/15s pair) before the loop +/// surfaces the error. +/// +/// Non-CL-height errors are passed through unchanged. Every rejection +/// is logged with both the proof's claimed height and Platform's +/// currently observed Core tip so persistent lag (>3.5min) attributes +/// to the specific DAPI node we hit and not to a generic timeout. +/// +/// **Cancellation:** not cancellation-safe. If the caller drops the +/// returned future mid-sleep, the bumped `user_fee_increase` is lost +/// and any in-flight submission whose response we never consume +/// remains queued in Tenderdash's mempool until it commits or expires. +/// Callers wrapping this in `tokio::select!` with a short timeout +/// should be prepared to either retry (settings reset to original) +/// or accept that Platform may still execute the dropped attempt. +pub async fn submit_with_cl_height_retry( + mut settings: Option, + submit: F, +) -> Result +where + F: Fn(Option) -> Fut, + Fut: std::future::Future>, +{ + let elapsed_since_start = retry_clock(); + let mut attempt: u32 = 0; + loop { + attempt += 1; + match submit(settings).await { + Ok(r) => return Ok(r), + Err(e) => { + let Some(detail) = as_asset_lock_proof_cl_height_too_low(&e) else { + return Err(e); + }; + let elapsed = elapsed_since_start(); + let remaining = CL_HEIGHT_RETRY_BUDGET.saturating_sub(elapsed); + if remaining.is_zero() || attempt >= CL_HEIGHT_RETRY_MAX_ATTEMPTS { + tracing::error!( + "Platform rejected ChainLock proof: CL height too low \ + (proof claimed height={}, Platform tip={}, attempt {}, \ + elapsed {}s) — retry budget of {}s exhausted; surfacing \ + error. Platform's reported tip is stuck — likely a lagging \ + or misbehaving DAPI node.", + detail.proof_core_chain_locked_height(), + detail.current_core_chain_locked_height(), + attempt, + elapsed.as_secs(), + CL_HEIGHT_RETRY_BUDGET.as_secs(), + ); + return Err(e); + } + let sleep_for = remaining.min(CL_HEIGHT_RETRY_DELAY); + tracing::warn!( + "Platform rejected ChainLock proof: CL height too low \ + (proof claimed height={}, Platform tip={}, attempt {}, \ + elapsed {}s); bumping user_fee_increase and waiting {}s \ + before retry", + detail.proof_core_chain_locked_height(), + detail.current_core_chain_locked_height(), + attempt, + elapsed.as_secs(), + sleep_for.as_secs(), + ); + settings = Some(bump_user_fee_increase(settings.unwrap_or_default())); + rs_dapi_client::transport::sleep(sleep_for).await; + } + } + } +} + +/// The time since the retry started. Natively this is tokio's clock, which a +/// paused-time test drives. `std::time::Instant` panics in the browser, so on +/// wasm32 it is JavaScript's `Date.now()`, as `internal_cache` reads it. +#[cfg(not(target_arch = "wasm32"))] +fn retry_clock() -> impl Fn() -> Duration { + let started = tokio::time::Instant::now(); + move || started.elapsed() +} + +#[cfg(target_arch = "wasm32")] +fn retry_clock() -> impl Fn() -> Duration { + let started = js_sys::Date::now(); + move || Duration::from_secs_f64((js_sys::Date::now() - started).max(0.0) / 1000.0) +} + +/// Bump `user_fee_increase` by 1 (saturating at `u16::MAX`). +fn bump_user_fee_increase(mut settings: PutSettings) -> PutSettings { + settings.user_fee_increase = Some(settings.user_fee_increase.unwrap_or(0).saturating_add(1)); + settings +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fabricate the SDK-side 10506 error shape exactly as + /// `as_asset_lock_proof_cl_height_too_low` recognizes it. Both the + /// matcher and the constructor are pinned here so a future SDK refactor + /// that changes the variant path can't silently desynchronize the retry + /// helper from its test surface. + fn fabricate_cl_height_too_low_error() -> Error { + let consensus = + ConsensusError::BasicError(BasicError::InvalidAssetLockProofCoreChainHeightError( + InvalidAssetLockProofCoreChainHeightError::new( + /* proof_core_chain_locked_height */ 100, + /* current_core_chain_locked_height */ 99, + ), + )); + Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus))) + } + + /// Pins two load-bearing invariants of `submit_with_cl_height_retry`: + /// + /// 1. Every retry under repeated `InvalidAssetLockProofCoreChainHeightError` + /// (consensus 10506) receives a `PutSettings::user_fee_increase` + /// strictly greater than the previous attempt. The retry's purpose + /// is to bypass Tenderdash's 24h invalid-tx hash cache by changing + /// the ST signable bytes; if `user_fee_increase` weren't bumped, + /// every resubmit would hash identically and be silently dropped. + /// This invariant regressed silently once in the earlier + /// swift-funding-with-asset-lock series — the test exists so it + /// can't regress quietly again. + /// + /// 2. After `CL_HEIGHT_RETRY_BUDGET` elapses without a non-10506 + /// outcome, the helper surfaces the original 10506 error rather + /// than looping forever or swallowing it. + /// + /// Driven by `#[tokio::test(start_paused = true)]` + manual + /// `tokio::time::advance` so the retry's `CL_HEIGHT_RETRY_DELAY` + /// sleeps fire instantly and total test wall time is sub-millisecond. + #[tokio::test(start_paused = true)] + async fn submit_with_cl_height_retry_bumps_user_fee_and_surfaces_after_budget() { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + use tokio::sync::Mutex; + + // Capture each invocation's `user_fee_increase` (None on the + // first call, then Some(N) for each retry). Shared `Mutex` + // because the closure is `Fn` and each future is independent. + let captured: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let call_count = Arc::new(AtomicU32::new(0)); + let captured_clone = captured.clone(); + let call_count_clone = call_count.clone(); + + // Stub `submit` closure: always returns 10506 so the retry loop + // exhausts its budget. The helper's return type is generic over + // `R`; pin `R = ()` for this test (we never reach the success + // path). + let submit = move |settings: Option| { + let captured = captured_clone.clone(); + let call_count = call_count_clone.clone(); + async move { + call_count.fetch_add(1, Ordering::SeqCst); + captured + .lock() + .await + .push(settings.and_then(|s| s.user_fee_increase)); + Err::<(), _>(fabricate_cl_height_too_low_error()) + } + }; + + let result = submit_with_cl_height_retry(None, submit).await; + + // Surfaced error must be the original 10506 — not a wrapper, not + // a "timeout" type, not None. + assert!( + result.is_err(), + "retry must surface the underlying error on budget exhaust" + ); + let surfaced_err = result.unwrap_err(); + assert!( + as_asset_lock_proof_cl_height_too_low(&surfaced_err).is_some(), + "surfaced error must still be the InvalidAssetLockProofCoreChainHeightError" + ); + + let captured = captured.lock().await; + let call_n = call_count.load(Ordering::SeqCst); + + // At least 2 attempts (initial + at least one retry); upper + // bound is `budget / delay` + 1 with a small slack for the + // boundary check. + let max_expected = + (CL_HEIGHT_RETRY_BUDGET.as_secs() / CL_HEIGHT_RETRY_DELAY.as_secs()) as u32 + 2; + assert!( + call_n >= 2 && call_n <= max_expected, + "expected 2..={max_expected} attempts (initial + retries up to budget), got {call_n}" + ); + assert_eq!( + captured.len() as u32, + call_n, + "every closure invocation should have recorded a fee value" + ); + + // First attempt: caller-supplied `None` settings → user_fee_increase = None. + assert_eq!( + captured[0], None, + "first attempt must use the caller-supplied `None` settings (no bump yet)" + ); + + // Subsequent attempts: strictly increasing `user_fee_increase`, + // starting from Some(1) and bumping by 1 each retry. The exact + // values are load-bearing: Tenderdash hashes the full ST bytes + // including this field, so consecutive identical values would + // hit the 24h invalid-tx cache. + for (i, val) in captured.iter().enumerate().skip(1) { + let expected = Some(i as u16); + assert_eq!( + *val, expected, + "attempt #{i} (1-indexed retry) must carry user_fee_increase = {expected:?}, got {val:?}" + ); + } + } +} + +#[cfg(test)] +mod asset_lock_already_consumed_tests { + use super::*; + use crate::error::StateTransitionBroadcastError; + use dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError; + use dpp::consensus::basic::UnsupportedProtocolVersionError; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::Txid; + + fn out_point() -> OutPoint { + OutPoint::new(Txid::all_zeros(), 7) + } + + fn consensus_error() -> ConsensusError { + let out_point = out_point(); + IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( + out_point.txid, + out_point.vout as usize, + ) + .into() + } + + fn unrelated_consensus_error() -> ConsensusError { + UnsupportedProtocolVersionError::new(2, 1).into() + } + + #[test] + fn recognizes_protocol_consensus_error_for_exact_outpoint() { + let error = Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error()))); + + assert!(is_asset_lock_already_consumed(&error, &out_point())); + } + + #[test] + fn recognizes_broadcast_consensus_error_for_exact_outpoint() { + let error = Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 10504, + message: "asset lock already consumed".to_string(), + cause: Some(consensus_error()), + }); + + assert!(is_asset_lock_already_consumed(&error, &out_point())); + } + + #[test] + fn ignores_unrelated_errors_and_different_outpoints() { + let error = Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error()))); + let different_out_point = OutPoint::new(Txid::all_zeros(), 8); + + assert!(!is_asset_lock_already_consumed( + &error, + &different_out_point + )); + assert!(!is_asset_lock_already_consumed( + &Error::Generic("boom".to_string()), + &out_point() + )); + + let unrelated_protocol = Error::Protocol(ProtocolError::ConsensusError(Box::new( + unrelated_consensus_error(), + ))); + assert!(!is_asset_lock_already_consumed( + &unrelated_protocol, + &out_point() + )); + + let unrelated_broadcast = + Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 10504, + // Deliberately resembles the target message: matching must + // depend on the structured cause, never this display text. + message: "asset lock output already completely used".to_string(), + cause: Some(unrelated_consensus_error()), + }); + assert!(!is_asset_lock_already_consumed( + &unrelated_broadcast, + &out_point() + )); + } +} diff --git a/packages/rs-sdk/src/platform/transition/top_up_address.rs b/packages/rs-sdk/src/platform/transition/top_up_address.rs index e8de2cc33c6..88cdf2003dc 100644 --- a/packages/rs-sdk/src/platform/transition/top_up_address.rs +++ b/packages/rs-sdk/src/platform/transition/top_up_address.rs @@ -192,8 +192,8 @@ impl> TopUpAddress for AddressesWithBalances { } // Pull `user_fee_increase` from settings *before* the - // broadcast call. The upstream CL-height retry path - // (`platform-wallet::wallet::asset_lock::orchestration::submit_with_cl_height_retry`) + // broadcast call. The CL-height retry path + // (`asset_lock_submission::submit_with_cl_height_retry`) // bumps this value between attempts to change the ST's // signable bytes — if we silently dropped it here, retries // would hash identically and get cached out by Tenderdash. From 2f383d9b378193ef8cfbb0e4324ad748c65cc84f Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 00:43:57 -0500 Subject: [PATCH 07/20] feat(sdk): DashPay invitation link codec and claim in rs-sdk Move the DashPay invitation link codec and the invitee-side claim from rs-platform-wallet into dash_sdk::platform::dashpay, so a wallet that does not use rs-platform-wallet can read and claim the links iOS and Android create: - encode_invitation_uri and the bounded parse_invitation_uri, the voucher credit-output selection, and the invitation amount and TTL limits; - Sdk::invitation_prospective_identity_id and Sdk::invitation_claim_status, which refetch the funding transaction and select the voucher output; - Sdk::claim_invitation: rebuild the asset-lock proof from the link, submit the identity create signed by the voucher key, fall back to a ChainLock proof when Platform rejects a stale islock, and adopt an earlier claim that already landed. Errors are an InvitationError that rs-platform-wallet maps back onto the variants it used before, so the FFI's codes and messages are unchanged. The tests move with the code. rs-platform-wallet keeps invitation creation (L1 funding, the invitation row, the funding-address pool) and the claim's identity-manager update, and re-exports the moved items at their old crypto:: and network:: paths. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/invitation.rs | 13 +- packages/rs-platform-wallet/src/error.rs | 17 + .../src/wallet/identity/crypto/invitation.rs | 914 ------- .../src/wallet/identity/crypto/mod.rs | 9 +- .../src/wallet/identity/network/invitation.rs | 1551 +---------- .../rs-sdk/src/platform/dashpay/invitation.rs | 2422 +++++++++++++++++ packages/rs-sdk/src/platform/dashpay/mod.rs | 10 +- 7 files changed, 2484 insertions(+), 2452 deletions(-) delete mode 100644 packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs create mode 100644 packages/rs-sdk/src/platform/dashpay/invitation.rs diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index d73d5281a1d..9c766dfcb68 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -32,6 +32,7 @@ use dpp::identity::accessors::IdentityGettersV0; use platform_wallet::wallet::identity::crypto::{ parse_invitation_uri, InviterInfo, ParsedInvitation, }; +use platform_wallet::PlatformWalletError; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; use platform_wallet::wallet::identity::network::{ @@ -303,7 +304,8 @@ pub unsafe extern "C" fn platform_wallet_claim_invitation( // (scheme/host, size cap, WIF network+compression) is checked here; the // funding tx is fetched and the credit-output binding resolved inside // `claim_invitation`. - let invitation = unwrap_result_or_return!(parse_invitation_uri(&uri)); + let invitation = + unwrap_result_or_return!(parse_invitation_uri(&uri).map_err(PlatformWalletError::from)); let keys_map = match decode_identity_pubkeys(identity_pubkeys, identity_pubkeys_count) { Ok(m) => m, Err(e) => return e, @@ -342,6 +344,7 @@ pub unsafe extern "C" fn platform_wallet_claim_invitation( /// falling through the generic arm into "proceed anyway". fn parse_invitation_for_query(uri: &str) -> Result { parse_invitation_uri(uri).map_err(|e| { + let e = PlatformWalletError::from(e); PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, format!("invitation link is malformed and cannot be claimed: {e}"), @@ -682,10 +685,10 @@ mod tests { use dashcore::secp256k1::SecretKey; use dashcore::{Network, PrivateKey}; - // Marshalling-boundary coverage. The invitation crypto/codec semantics are - // pinned library-side in `platform_wallet`'s `crypto::invitation` + - // `network::invitation`; these tests only exercise the FFI's null/parameter - // guards and the wallet-lookup miss path. + // Marshalling-boundary coverage. The invitation codec and claim semantics + // are pinned library-side in `dash_sdk::platform::dashpay`'s invitation + // module and `platform_wallet`'s `network::invitation`; these tests only + // exercise the FFI's null/parameter guards and the wallet-lookup miss path. /// A null `core_signer_handle` is rejected with `ErrorNullPointer` before /// any wallet lookup (the `check_ptr!` contract). diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index a202ef4a1f8..870e9be0323 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1,3 +1,4 @@ +use dash_sdk::platform::dashpay::InvitationError; use dpp::address_funds::PlatformAddress; use dpp::consensus::state::address_funds::AddressInvalidNonceError; use dpp::consensus::ConsensusError; @@ -1001,6 +1002,22 @@ pub enum PlatformWalletError { ShieldedNotBound, } +/// The SDK's invitation errors map onto the variants the wallet used before +/// the invitation codec and claim moved into `dash-sdk`, so hosts see the same +/// error codes and messages. +impl From for PlatformWalletError { + fn from(error: InvitationError) -> Self { + match error { + InvitationError::Invalid(message) => Self::InvalidIdentityData(message), + InvitationError::NetworkMismatch { invitation, wallet } => { + Self::InvitationNetworkMismatch { invitation, wallet } + } + InvitationError::NotChainLocked(message) => Self::AssetLockNotChainLocked(message), + InvitationError::Sdk(error) => Self::Sdk(error), + } + } +} + impl PlatformWalletError { /// A persister `load` failed. /// diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs deleted file mode 100644 index 884d3ce4292..00000000000 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs +++ /dev/null @@ -1,914 +0,0 @@ -//! DashPay invitation link codec — DIP-13 sub-feature 3', legacy-compatible. -//! -//! An invitation packages a one-time ECDSA **voucher** private key together with -//! a reference to the InstantSend-locked funding transaction, so an invitee with -//! no Dash can register their own identity from it. The inviter optionally -//! includes their own DPNS username so the invitee can send a contact request -//! back. -//! -//! The link is the **legacy query form** shared with the reference wallets -//! (`dash-wallet` Android, `dashwallet-ios`), so a link produced here is -//! field-level cross-claimable with those wallets and vice versa: -//! -//! ```text -//! dashpay://invite -//! ?du= -//! &assetlocktx= -//! &pk= -//! &islock= # or omitted / "null" -//! [&display-name=] -//! [&avatar-url=] -//! ``` -//! -//! The interop contract is **emit strict/canonical, parse lenient** — exactly as -//! tolerantly as the live wallets: -//! - Parse accepts both the `dashpay://invite` scheme and the -//! `https://invitations.dashpay.io/applink` host, by field name and -//! order-independent (the two legacy wallets differ in param order). -//! - `islock` is optional: a missing param **and** the literal string `"null"` -//! (which Android emits for chainlock-confirmed invites) both mean "no instant -//! lock" — the claim reconstructs a ChainLock proof instead. The hex decodes -//! as the deterministic InstantSend lock; a lock in the older -//! non-deterministic format does not decode, so the claim fails rather than -//! guessing (no live wallet emits that format). -//! - `assetlocktx` is kept as the raw hex string; the claim tries it as-given -//! then byte-reversed on a fetch miss, mirroring the legacy endianness retry. -//! -//! # Security -//! -//! The `voucher_key` is **bearer money** — whoever holds the link can claim the -//! funded identity. The URI is a secret: callers MUST NOT log or persist it, and -//! the voucher key is never stored (it is HD-derived and re-derivable from the -//! funding index). Parsing is bounded (URI length cap) so a hostile link can't -//! force a large allocation; the WIF is decoded + compression-checked at parse, -//! and its network is validated against the wallet at claim (a wrong-network WIF -//! is a valid key on the wrong chain, caught before the funding fetch). -//! -//! What the design does and does not protect against: -//! - Every theft reduces to "who holds the link". Platform checks the claim's -//! signature against the key of the asset-lock output, and the new identity's -//! id is derived from the funding outpoint, so someone who watches a claim in -//! flight but lacks the voucher key cannot redirect it, and two racing claims -//! target the same identity (exactly one commits). -//! - The inviter can always re-derive the voucher key, so after handing over a -//! link it can still claim or reclaim the voucher first. Nothing is stolen -//! (the funds were the inviter's), but the invitee is denied onboarding with -//! no sign of why. For the same reason, reclaiming a leaked link is a race the -//! inviter can lose. -//! - Nothing signs the link, so whoever controls the channel it travels over -//! can swap in a different invite. The worst outcome is the invitee sending a -//! contact request to the attacker's identity, which an ordinary contact -//! request achieves anyway. Signing would not help: the channel is the trust -//! root. -//! - Because the identity id comes from the funding outpoint, the inviter knows -//! the invitee's identity id before the invitee claims it. -//! - The legacy wallets wrapped the link in an AppsFlyer OneLink, which sends -//! the plaintext `pk` to AppsFlyer's servers. We parse that host but never -//! emit it. - -use dashcore::secp256k1::{PublicKey, SecretKey}; -use dashcore::transaction::special_transaction::TransactionPayload; -use dashcore::{Network, PrivateKey, ScriptBuf, Transaction, TxOut}; -use dpp::prelude::AssetLockProof; - -use crate::error::PlatformWalletError; - -/// The `dashpay://invite` custom scheme — the canonical form we emit and the -/// primary form we parse (QR / in-person / deep link). `invite` is the URI -/// authority (Android's `URI_PREFIX`), so the accepted host is exactly `invite`. -const INVITATION_SCHEME: &str = "dashpay"; -const INVITATION_SCHEME_HOST: &str = "invite"; -/// The full canonical prefix we emit. -const INVITATION_SCHEME_PREFIX: &str = "dashpay://invite"; - -/// The AppsFlyer OneLink applink the iOS reference wallet emits -/// (`https://invitations.dashpay.io/applink?…`). Parsed as a first-class -/// alternative to the custom scheme (field-level interop). -const INVITATION_APPLINK_HOST: &str = "invitations.dashpay.io"; -const INVITATION_APPLINK_PATH: &str = "/applink"; - -/// Query parameter names — identical to the legacy wallets' contract. -const PARAM_USER: &str = "du"; -const PARAM_ASSET_LOCK_TX: &str = "assetlocktx"; -const PARAM_PRIVATE_KEY: &str = "pk"; -const PARAM_IS_LOCK: &str = "islock"; -const PARAM_DISPLAY_NAME: &str = "display-name"; -const PARAM_AVATAR_URL: &str = "avatar-url"; - -/// Android emits this literal for the `islock` value when the funding was -/// confirmed by a ChainLock rather than an InstantSend lock; treat it as "no -/// instant lock" (reconstruct a ChainLock proof at claim), NOT as hex to decode. -const IS_LOCK_NULL_SENTINEL: &str = "null"; - -/// Max chars of the whole URI accepted before parsing (anti-DoS). A real link — -/// username + txid (64) + WIF (~52) + islock hex (~400) + optional avatar url — -/// is well under 2 KB; 8192 is comfortable headroom while bounding the -/// allocation a hostile link can force. -const MAX_INVITATION_URI_LEN: usize = 8192; - -/// Max length (bytes) of a UTF-8 string field (username / display name / avatar -/// url). DPNS labels are short; this only bounds a hostile link. -const MAX_STR_BYTES: usize = 2048; - -/// Inviter contact-bootstrap info — present iff the link carries any of `du`, -/// `display-name`, or `avatar-url`. Absent ⇒ pure funding voucher. The link does -/// not carry the inviter's identity id (the legacy format has no such field); -/// the invitee resolves it from `username` via DPNS at contact-bootstrap time. -/// -/// `username` is optional: the reference wallets emit `display-name`/`avatar-url` -/// in blocks independent of `du`, so a `du`-less link can still carry inviter -/// metadata (surfaced in the preview even though no contact request is possible -/// without a username). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct InviterInfo { - /// The inviter's DPNS username (`du`), shown to the invitee and used to - /// resolve the inviter's identity for the contact request. `None` for a - /// metadata-only (`du`-less) link. - pub username: Option, - /// Optional display name (`display-name`) for the claim UI. - pub display_name: Option, - /// Optional avatar url (`avatar-url`) for the claim UI. - pub avatar_url: Option, -} - -/// A decoded invitation, ready for claim. -/// -/// Unlike the funding proof (which is fetched at claim), everything here comes -/// straight from the link: the bearer voucher key, the funding txid to fetch, -/// and the optional InstantSend lock hex. -pub struct ParsedInvitation { - /// One-time ECDSA voucher private key that funds the invitee's identity - /// create (signs the asset-lock's outer state-transition signature). - pub voucher_key: SecretKey, - /// The network the voucher key's WIF was encoded for (`0xCC` ⇒ Mainnet, - /// `0xEF` ⇒ the testnet family). The claim path rejects a link whose WIF - /// network does not match the wallet's, turning a wrong-network link into a - /// clear error instead of a mysterious fetch miss. - pub voucher_key_network: Network, - /// The funding transaction id as carried in the link (`assetlocktx`), - /// lowercased. Kept as the raw hex string so the claim can try it as-given - /// and byte-reversed on a fetch miss (old iOS links are little-endian). - pub funding_txid: String, - /// The InstantSend lock, lowercase consensus hex — `None` when the link - /// omitted `islock` or set it to `"null"` (a ChainLock-confirmed invite). - pub islock_hex: Option, - /// Inviter contact-bootstrap info; `None` ⇒ pure funding voucher (no `du`). - pub inviter: Option, -} - -impl Drop for ParsedInvitation { - /// Scrub the voucher scalar on drop — it is literal bearer money. Mirrors - /// the resolver signer's key hygiene (`WipingSecretKey`). - fn drop(&mut self) { - self.voucher_key.non_secure_erase(); - } -} - -impl std::fmt::Debug for ParsedInvitation { - /// Redacts the voucher key and the funding txid — the whole point of the - /// type is to carry a bearer secret, which must never reach a log. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ParsedInvitation") - .field("voucher_key", &"") - .field("funding_txid", &"") - .field("has_islock", &self.islock_hex.is_some()) - .field("inviter", &self.inviter) - .finish_non_exhaustive() - } -} - -fn invalid(msg: impl Into) -> PlatformWalletError { - PlatformWalletError::InvalidIdentityData(msg.into()) -} - -/// The P2PKH script the voucher key controls (compressed-pubkey hash160). This -/// is the selector that binds the voucher key to its funded credit output. -/// `pub(crate)` so the claim-side proof-assembly tests can build a funding tx. -pub(crate) fn voucher_credit_script(voucher_key: &SecretKey) -> ScriptBuf { - let pubkey = PublicKey::from_secret_key(voucher_key); - let hash = dashcore::PublicKey::new(pubkey).pubkey_hash(); - ScriptBuf::new_p2pkh(&hash) -} - -// --------------------------------------------------------------------------- -// Percent-encoding (URI query values). Only %XX escapes — never `+` for space -// (that is form encoding; `Uri.getQueryParameter` on the legacy wallets does -// not treat `+` as space, so neither do we). -// --------------------------------------------------------------------------- - -/// Percent-encode a query value, passing the RFC 3986 unreserved set through -/// untouched (which covers hex, base58 WIF, and DPNS labels) and `%`-escaping -/// everything else. Encoding an already-safe value is a no-op. -fn percent_encode(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for &b in s.as_bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) - } - _ => out.push_str(&format!("%{b:02X}")), - } - } - out -} - -/// Decode a percent-encoded query value (`%XX` → byte). Leaves `+` literal. -/// Errors on a malformed escape or non-UTF-8 result. -fn percent_decode(s: &str) -> Result { - let bytes = s.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' { - let hi = bytes - .get(i + 1) - .and_then(|c| (*c as char).to_digit(16)) - .ok_or_else(|| invalid("malformed percent-escape in invitation link"))?; - let lo = bytes - .get(i + 2) - .and_then(|c| (*c as char).to_digit(16)) - .ok_or_else(|| invalid("malformed percent-escape in invitation link"))?; - out.push((hi * 16 + lo) as u8); - i += 3; - } else { - out.push(bytes[i]); - i += 1; - } - } - String::from_utf8(out).map_err(|_| invalid("invitation link field is not valid UTF-8")) -} - -/// Split a query string (`k1=v1&k2=v2`) into percent-decoded key/value pairs, -/// order-independent. A blank segment or a segment without `=` is skipped. -fn parse_query(query: &str) -> Result, PlatformWalletError> { - let mut pairs = Vec::new(); - for segment in query.split('&') { - if segment.is_empty() { - continue; - } - let Some((raw_key, raw_val)) = segment.split_once('=') else { - continue; - }; - pairs.push((percent_decode(raw_key)?, percent_decode(raw_val)?)); - } - Ok(pairs) -} - -/// Look up a field by name (first match wins), returning it trimmed. `None` for -/// a missing or blank value. -fn field<'a>(pairs: &'a [(String, String)], name: &str) -> Option<&'a str> { - pairs - .iter() - .find(|(k, _)| k == name) - .map(|(_, v)| v.trim()) - .filter(|v| !v.is_empty()) -} - -/// The parts of an invitation URI we gate on. Extracted with a small hand parser -/// (no `url` crate in this crate's deps) so the transport check is anchored to -/// the real scheme/host/path — not a substring/prefix that a decoy URL could -/// smuggle (`dashpay://inviteXYZ`, `https://evil/?next=invitations.dashpay.io/applink&…`). -struct UriParts<'a> { - scheme: &'a str, - /// Authority host, lowercased for comparison (userinfo + port stripped). - host: String, - path: &'a str, - query: Option<&'a str>, -} - -/// Strip `userinfo@` and `:port` from an authority, leaving the bare host. -fn authority_host(authority: &str) -> &str { - let after_userinfo = authority.rsplit_once('@').map_or(authority, |(_, h)| h); - // A bare `host:port` — take the host. (Our hosts are never bracketed IPv6.) - after_userinfo.split(':').next().unwrap_or(after_userinfo) -} - -/// Split `scheme://authority/path?query#fragment` into the parts we gate on. -/// Requires the `://` authority form (both accepted transports use it) and drops -/// the fragment. Returns `None` for a shape we don't recognize as a URI. -fn split_uri(uri: &str) -> Option> { - let (scheme, rest) = uri.split_once(':')?; - let rest = rest.strip_prefix("//")?; - let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); - let authority = &rest[..auth_end]; - let after_auth = &rest[auth_end..]; - // Drop the fragment, then split path from query. - let after_auth = after_auth.split('#').next().unwrap_or(after_auth); - let (path, query) = match after_auth.split_once('?') { - Some((p, q)) => (p, Some(q)), - None => (after_auth, None), - }; - Some(UriParts { - scheme, - host: authority_host(authority).to_ascii_lowercase(), - path, - query, - }) -} - -/// Encode an invitation into a legacy-compatible `dashpay://invite?…` link. -/// -/// The returned URI **contains the plaintext voucher key** (WIF) — treat it as a -/// secret (do not log or persist it). `network` sets the WIF network byte -/// (`0xCC` mainnet / `0xEF` testnet) and the key is emitted **compressed** (the -/// credit-output hash160 uses the compressed pubkey). The funding txid and -/// InstantSend lock are read from `asset_lock`; a ChainLock proof emits no -/// `islock`. `inviter` is `Some` only when the inviter opted into the -/// contact-bootstrap (the link then carries `du`/`display-name`/`avatar-url`). -pub fn encode_invitation_uri( - voucher_key: &SecretKey, - network: Network, - asset_lock: &AssetLockProof, - inviter: Option<&InviterInfo>, -) -> Result { - // txid (big-endian display hex) + optional islock hex from the proof. - let (funding_txid, islock_hex) = match asset_lock { - AssetLockProof::Instant(instant) => { - let txid = instant.transaction().txid().to_string(); - let islock = hex::encode(dashcore::consensus::serialize(instant.instant_lock())); - (txid, Some(islock)) - } - AssetLockProof::Chain(chain) => (chain.out_point.txid.to_string(), None), - }; - - // WIF, compressed (the default for `PrivateKey::new`), network-correct. - let wif = PrivateKey::new(*voucher_key, network).to_wif(); - - let mut query = String::new(); - let mut push = |key: &str, val: &str| { - if !query.is_empty() { - query.push('&'); - } - query.push_str(key); - query.push('='); - query.push_str(&percent_encode(val)); - }; - - // `du` is emitted first (canonical) when the inviter opted in with a - // username; a pure funding voucher emits a `du`-less link (still parseable — - // iOS accepts it). - if let Some(username) = inviter.and_then(|info| info.username.as_ref()) { - if username.len() > MAX_STR_BYTES { - return Err(invalid("inviter username too long")); - } - push(PARAM_USER, username); - } - push(PARAM_ASSET_LOCK_TX, &funding_txid); - push(PARAM_PRIVATE_KEY, &wif); - if let Some(islock) = &islock_hex { - push(PARAM_IS_LOCK, islock); - } - if let Some(info) = inviter { - if let Some(display_name) = &info.display_name { - if display_name.len() > MAX_STR_BYTES { - return Err(invalid("inviter display name too long")); - } - push(PARAM_DISPLAY_NAME, display_name); - } - if let Some(avatar_url) = &info.avatar_url { - if avatar_url.len() > MAX_STR_BYTES { - return Err(invalid("inviter avatar url too long")); - } - push(PARAM_AVATAR_URL, avatar_url); - } - } - - let uri = format!("{INVITATION_SCHEME_PREFIX}?{query}"); - // Reject a link the parser itself would reject. `MAX_STR_BYTES` bounds each - // field's RAW bytes, but percent-encoding can expand a field up to 3×, so - // several near-cap fields can push the formatted URI past - // `MAX_INVITATION_URI_LEN` (the exact cap `parse_invitation_uri` enforces). - // Fail here rather than emit a self-unparseable link — the create path - // surfaces this as a hard error *after* the funded voucher is recorded, so it - // stays reclaimable. - if uri.len() > MAX_INVITATION_URI_LEN { - return Err(invalid(format!( - "invitation link too long ({} chars; max {MAX_INVITATION_URI_LEN})", - uri.len() - ))); - } - Ok(uri) -} - -/// Parse a legacy-compatible invitation link into a [`ParsedInvitation`]. -/// -/// Accepts exactly the `dashpay://invite` scheme (host `invite`) and the -/// `https://invitations.dashpay.io/applink` URL (host + path anchored, so a -/// decoy URL that merely mentions the applink cannot smuggle a query in). Fields -/// are matched by name, order-independent. Requires `assetlocktx` + `pk` -/// (non-blank, each appearing at most once); `du` and `islock` are optional (a -/// missing/`"null"` `islock` is a ChainLock invite). The WIF is decoded and -/// compression-checked here; its **network** is captured for the claim path to -/// validate against the wallet (a wrong-network WIF is a valid key on the wrong -/// chain, so it must be caught there, not here). Does **not** fetch or validate -/// the funding tx — that happens at claim. -pub fn parse_invitation_uri(uri: &str) -> Result { - if uri.len() > MAX_INVITATION_URI_LEN { - return Err(invalid(format!( - "invitation link too long ({} chars; max {MAX_INVITATION_URI_LEN})", - uri.len() - ))); - } - - // Anchor the transport gate to the real scheme/host/path, not a prefix or - // substring: `dashpay://invite` (host == `invite`) or the applink URL - // (host == invitations.dashpay.io, path == /applink). A trailing slash on - // the applink path is tolerated. - let parts = split_uri(uri).ok_or_else(|| invalid("not a valid invitation link URI"))?; - let accepted = match parts.scheme.to_ascii_lowercase().as_str() { - INVITATION_SCHEME => { - parts.host == INVITATION_SCHEME_HOST && (parts.path.is_empty() || parts.path == "/") - } - "https" | "http" => { - parts.host == INVITATION_APPLINK_HOST - && parts.path.trim_end_matches('/') == INVITATION_APPLINK_PATH - } - _ => false, - }; - if !accepted { - return Err(invalid( - "not a dashpay://invite or https://invitations.dashpay.io/applink link", - )); - } - - let query = parts - .query - .ok_or_else(|| invalid("invitation link has no query parameters"))?; - let pairs = parse_query(query)?; - - // A duplicated required key is ambiguous and dangerous: the two reference - // wallets bind different occurrences (iOS the last, Android the first), so a - // `?pk=A&pk=B` link would claim different keys per wallet. Reject it. - for key in [PARAM_PRIVATE_KEY, PARAM_ASSET_LOCK_TX] { - if pairs.iter().filter(|(k, _)| k == key).count() > 1 { - return Err(invalid(format!( - "invitation link has a duplicated `{key}` field" - ))); - } - } - - // iOS minimum: `assetlocktx` + `pk` present and non-blank. Never reject on a - // missing/`"null"` `islock` or a missing `du`. - let assetlocktx = field(&pairs, PARAM_ASSET_LOCK_TX) - .ok_or_else(|| invalid("invitation link is missing the assetlocktx field"))?; - let pk = field(&pairs, PARAM_PRIVATE_KEY) - .ok_or_else(|| invalid("invitation link is missing the pk field"))?; - - // WIF: decode + require compression (the credit-output hash uses the - // compressed pubkey — an uncompressed key would produce a mismatching - // hash160 and a dead claim). `from_wif` rejects a non-Dash network byte but - // cannot tell mainnet from testnet against *this* wallet; the claim path does - // that (see `voucher_key_network`). - // `PrivateKey`/`SecretKey` are `Copy`, so the decoded binding is a second - // bearer-scalar copy alongside `voucher_key` — erase it on every exit - // (rejection included; `ParsedInvitation`'s wipe-on-drop guard doesn't - // exist yet on that path) to keep the parser's wiping policy airtight. - let mut private_key = PrivateKey::from_wif(pk) - .map_err(|e| invalid(format!("invitation pk is not a valid WIF key: {e}")))?; - if !private_key.compressed { - private_key.inner.non_secure_erase(); - return Err(invalid("invitation pk must be a compressed WIF key")); - } - let voucher_key = private_key.inner; - let voucher_key_network = private_key.network; - private_key.inner.non_secure_erase(); - - let funding_txid = assetlocktx.to_lowercase(); - - // `islock`: a missing param and the literal `"null"` both mean no instant - // lock. Kept as lowercase hex; decoded to an `InstantLock` only at claim. - let islock_hex = field(&pairs, PARAM_IS_LOCK) - .filter(|v| *v != IS_LOCK_NULL_SENTINEL) - .map(|v| v.to_lowercase()); - - // Inviter metadata is parsed field-by-field, independent of `du` (the - // reference wallets emit `display-name`/`avatar-url` in blocks separate from - // `du`, so a `du`-less link can still carry them). Present iff any field is. - // The identity id is not in the link; it is resolved from the username at - // contact-bootstrap time. - // - // Note: an unencoded `&` inside a legacy `avatar-url` would truncate that - // field at the naive `&` split. Cosmetic (avatar only, low likelihood) — not - // worth a full nested-query parser; the required fields are unaffected. - let username = field(&pairs, PARAM_USER).map(str::to_string); - let display_name = field(&pairs, PARAM_DISPLAY_NAME).map(str::to_string); - let avatar_url = field(&pairs, PARAM_AVATAR_URL).map(str::to_string); - let inviter = if username.is_some() || display_name.is_some() || avatar_url.is_some() { - Some(InviterInfo { - username, - display_name, - avatar_url, - }) - } else { - None - }; - - Ok(ParsedInvitation { - voucher_key, - voucher_key_network, - funding_txid, - islock_hex, - inviter, - }) -} - -/// Whether a voucher WIF encoded for `pk_network` is usable on a wallet running -/// `wallet_network`. -/// -/// WIF carries only two network bytes — `0xCC` (Mainnet) and `0xEF` (the whole -/// testnet family: Testnet/Devnet/Regtest) — so the meaningful check is -/// mainnet-vs-not: a testnet/devnet/regtest wallet accepts any `0xEF` WIF, and a -/// mainnet wallet accepts only a `0xCC` WIF. This catches a testnet link pasted -/// into a mainnet wallet (a valid key on the wrong chain) as a clear error -/// rather than a mysterious funding-tx fetch miss. -pub fn wif_network_matches(pk_network: Network, wallet_network: Network) -> bool { - matches!(pk_network, Network::Mainnet) == matches!(wallet_network, Network::Mainnet) -} - -/// Select the funded credit output the voucher key controls. -/// -/// The link carries the voucher key but not the output index; scan the fetched -/// asset-lock transaction's credit outputs for the one whose `script_pubkey` -/// matches the voucher key's P2PKH (compressed pubkey), and return its index. -/// Rejects a transaction with no matching credit output. The pk↔output binding -/// is itself consensus-enforced, so this is a fail-fast + correct-index guard, -/// not a security boundary — but selecting (rather than hard-coding index 0) is -/// required: a legacy invite's credit output need not be at index 0. -pub fn voucher_output_index( - transaction: &Transaction, - voucher_key: &SecretKey, -) -> Result { - voucher_credit_output(transaction, voucher_key).map(|(index, _)| index) -} - -/// [`voucher_output_index`] plus the selected credit output itself, so a -/// caller that needs the voucher's value reads it from the same match. -pub fn voucher_credit_output<'a>( - transaction: &'a Transaction, - voucher_key: &SecretKey, -) -> Result<(u32, &'a TxOut), PlatformWalletError> { - let Some(TransactionPayload::AssetLockPayloadType(payload)) = - &transaction.special_transaction_payload - else { - return Err(invalid( - "funding transaction is not an asset-lock special transaction", - )); - }; - let expected = voucher_credit_script(voucher_key); - payload - .credit_outputs - .iter() - .enumerate() - .find(|(_, out)| out.script_pubkey == expected) - .map(|(index, out)| (index as u32, out)) - .ok_or_else(|| { - invalid("voucher key does not control any credit output of the funding transaction") - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use dashcore::ephemerealdata::instant_lock::InstantLock; - use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; - use dashcore::{Transaction, TxOut}; - use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; - - fn voucher() -> SecretKey { - SecretKey::from_secret_bytes([0x11u8; 32]).expect("valid scalar") - } - - fn inviter_info() -> InviterInfo { - InviterInfo { - username: Some("alice".to_string()), - display_name: Some("Alice Example".to_string()), - avatar_url: Some("https://example.com/a b.png?x=1".to_string()), - } - } - - /// Build an asset-lock tx whose credit output at `index` pays the voucher - /// key (and `index` decoy outputs before it that do not). - fn asset_lock_tx_paying_voucher_at(key: &SecretKey, index: usize) -> Transaction { - let decoy = SecretKey::from_secret_bytes([0x22u8; 32]).unwrap(); - let mut credit_outputs = Vec::new(); - for _ in 0..index { - credit_outputs.push(TxOut { - value: 50_000, - script_pubkey: voucher_credit_script(&decoy), - }); - } - credit_outputs.push(TxOut { - value: 100_000, - script_pubkey: voucher_credit_script(key), - }); - let payload = AssetLockPayload { - version: 1, - credit_outputs, - }; - Transaction { - version: 3, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), - } - } - - fn instant_proof_paying_voucher() -> AssetLockProof { - let key = voucher(); - let tx = asset_lock_tx_paying_voucher_at(&key, 0); - AssetLockProof::Instant(InstantAssetLockProof::new(InstantLock::default(), tx, 0)) - } - - #[test] - fn wif_round_trip_preserves_compression_and_network() { - for (network, first_byte) in [(Network::Mainnet, 204u8), (Network::Testnet, 239u8)] { - let wif = PrivateKey::new(voucher(), network).to_wif(); - let decoded = PrivateKey::from_wif(&wif).expect("wif decodes"); - assert!(decoded.compressed, "voucher WIF must be compressed"); - assert_eq!(decoded.network, network, "network preserved"); - assert_eq!(decoded.inner.to_secret_bytes(), voucher().to_secret_bytes()); - // Network byte matches bitcoinj/legacy (0xCC mainnet, 0xEF testnet). - let raw = bs58::decode(&wif).into_vec().unwrap(); - assert_eq!(raw[0], first_byte); - } - } - - #[test] - fn encode_parse_round_trip_with_inviter() { - let proof = instant_proof_paying_voucher(); - let info = inviter_info(); - let uri = encode_invitation_uri(&voucher(), Network::Testnet, &proof, Some(&info)) - .expect("encode"); - assert!(uri.starts_with("dashpay://invite?")); - - let parsed = parse_invitation_uri(&uri).expect("parse"); - assert_eq!( - parsed.voucher_key.to_secret_bytes(), - voucher().to_secret_bytes() - ); - assert_eq!(parsed.inviter, Some(info)); - assert!(parsed.islock_hex.is_some()); - // The parsed txid matches the proof's transaction id (big-endian). - let expected_txid = match &proof { - AssetLockProof::Instant(i) => i.transaction().txid().to_string(), - _ => unreachable!(), - }; - assert_eq!(parsed.funding_txid, expected_txid); - } - - #[test] - fn encode_parse_round_trip_pure_voucher_no_inviter() { - let proof = instant_proof_paying_voucher(); - let uri = - encode_invitation_uri(&voucher(), Network::Mainnet, &proof, None).expect("encode"); - let parsed = parse_invitation_uri(&uri).expect("parse"); - assert!(parsed.inviter.is_none(), "du-less link ⇒ no inviter"); - assert!(!uri.contains("du="), "pure voucher emits no du"); - } - - /// Params in a non-canonical order must still parse (field-level, not - /// byte-level, interop — the two legacy wallets differ in order). - #[test] - fn parse_is_order_independent() { - let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); - let uri = format!("dashpay://invite?islock=deadbeef&pk={wif}&du=bob&assetlocktx=aabbcc"); - let parsed = parse_invitation_uri(&uri).expect("parse"); - assert_eq!(parsed.funding_txid, "aabbcc"); - assert_eq!(parsed.islock_hex.as_deref(), Some("deadbeef")); - assert_eq!( - parsed.inviter.as_ref().unwrap().username.as_deref(), - Some("bob") - ); - } - - /// The `https://invitations.dashpay.io/applink` host is accepted as a - /// first-class alternative to the custom scheme (iOS emits it). - #[test] - fn parse_accepts_applink_host() { - let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); - let uri = format!( - "https://invitations.dashpay.io/applink?du=carol&assetlocktx=aabb&pk={wif}&islock=cc" - ); - let parsed = parse_invitation_uri(&uri).expect("parse applink"); - assert_eq!( - parsed.inviter.as_ref().unwrap().username.as_deref(), - Some("carol") - ); - assert_eq!(parsed.funding_txid, "aabb"); - } - - /// `islock` present / absent / `"null"`: only a real hex value yields - /// `Some`; both a missing param and the literal `"null"` yield `None` - /// (a ChainLock-confirmed invite Android's own validator accepts). - #[test] - fn parse_islock_present_absent_and_null() { - let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); - let present = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=00aa11"); - assert_eq!( - parse_invitation_uri(&present) - .unwrap() - .islock_hex - .as_deref(), - Some("00aa11") - ); - - let absent = format!("dashpay://invite?assetlocktx=aa&pk={wif}"); - assert!(parse_invitation_uri(&absent).unwrap().islock_hex.is_none()); - - let null = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=null"); - assert!( - parse_invitation_uri(&null).unwrap().islock_hex.is_none(), - "islock=null must be treated as no instant lock" - ); - } - - /// A `du`-less link still parses (iOS accepts du-less links). - #[test] - fn parse_accepts_du_less_link() { - let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); - let uri = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=bb"); - let parsed = parse_invitation_uri(&uri).expect("parse"); - assert!(parsed.inviter.is_none()); - } - - #[test] - fn parse_rejects_missing_pk_or_assetlocktx() { - let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); - // No pk. - assert!(parse_invitation_uri("dashpay://invite?assetlocktx=aa").is_err()); - // No assetlocktx. - assert!(parse_invitation_uri(&format!("dashpay://invite?pk={wif}")).is_err()); - // Blank assetlocktx is treated as missing. - assert!(parse_invitation_uri(&format!("dashpay://invite?assetlocktx=&pk={wif}")).is_err()); - } - - #[test] - fn parse_rejects_wrong_scheme_and_host() { - assert!(parse_invitation_uri("https://example.com/foo?pk=x").is_err()); - assert!(parse_invitation_uri("dashpay://contact?pk=x").is_err()); - } - - #[test] - fn parse_rejects_uncompressed_wif() { - let uncompressed = PrivateKey::new_uncompressed(voucher(), Network::Testnet).to_wif(); - let uri = format!("dashpay://invite?assetlocktx=aa&pk={uncompressed}"); - let err = parse_invitation_uri(&uri).unwrap_err(); - assert!(err.to_string().contains("compressed")); - } - - #[test] - fn parse_rejects_bad_wif() { - let err = parse_invitation_uri("dashpay://invite?assetlocktx=aa&pk=not-a-wif").unwrap_err(); - assert!(err.to_string().contains("WIF")); - } - - #[test] - fn parse_rejects_oversized_uri() { - let huge = format!("dashpay://invite?pk={}", "z".repeat(MAX_INVITATION_URI_LEN)); - let err = parse_invitation_uri(&huge).unwrap_err(); - assert!(err.to_string().contains("too long")); - } - - /// A hand-crafted Android-style link (params in Android's emit order, with - /// display-name + avatar-url) parses to the right fields. - #[test] - fn parse_android_style_link() { - let wif = PrivateKey::new(voucher(), Network::Mainnet).to_wif(); - // Android order: du, assetlocktx, pk, islock, display-name, avatar-url. - let uri = format!( - "dashpay://invite?du=satoshi&assetlocktx={txid}&pk={wif}&islock={islock}&display-name=Sat%20Oshi&avatar-url=https%3A%2F%2Fimg.example%2Fa.png", - txid = "e8b43025641eea4fd21190f01bd870ef90f1a8b199d8fc3376c5b62c0b1a179d", - islock = "01" - ); - let parsed = parse_invitation_uri(&uri).expect("parse android link"); - assert_eq!( - parsed.funding_txid, - "e8b43025641eea4fd21190f01bd870ef90f1a8b199d8fc3376c5b62c0b1a179d" - ); - assert_eq!(parsed.islock_hex.as_deref(), Some("01")); - let inviter = parsed.inviter.as_ref().expect("inviter"); - assert_eq!(inviter.username.as_deref(), Some("satoshi")); - assert_eq!(inviter.display_name.as_deref(), Some("Sat Oshi")); - assert_eq!( - inviter.avatar_url.as_deref(), - Some("https://img.example/a.png") - ); - } - - /// The voucher output is selected by pk↔script match, not hard-coded to 0. - #[test] - fn voucher_output_index_selects_matching_output() { - let key = voucher(); - // Voucher output sits at index 2, behind two decoy outputs. - let tx = asset_lock_tx_paying_voucher_at(&key, 2); - assert_eq!(voucher_output_index(&tx, &key).unwrap(), 2); - } - - /// The credit output comes back with its index, so its value is the - /// voucher's, not a decoy's. - #[test] - fn should_return_the_voucher_credit_output_with_its_index() { - let key = voucher(); - let tx = asset_lock_tx_paying_voucher_at(&key, 2); - let (index, output) = voucher_credit_output(&tx, &key).unwrap(); - assert_eq!(index, 2); - assert_eq!(output.value, 100_000, "the decoys before it carry 50_000"); - assert_eq!(output.script_pubkey, voucher_credit_script(&key)); - } - - #[test] - fn voucher_output_index_rejects_no_match() { - let key = voucher(); - let other = SecretKey::from_secret_bytes([0x33u8; 32]).unwrap(); - let tx = asset_lock_tx_paying_voucher_at(&other, 0); - let err = voucher_output_index(&tx, &key).unwrap_err(); - assert!(err.to_string().contains("does not control")); - } - - #[test] - fn voucher_output_index_rejects_non_asset_lock_tx() { - let key = voucher(); - let tx = Transaction { - version: 2, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: None, - }; - assert!(voucher_output_index(&tx, &key).is_err()); - } - - /// A `du`-less link that still carries `display-name`/`avatar-url` must - /// surface them (the reference wallets emit those independently of `du`). - #[test] - fn parse_du_less_link_keeps_metadata() { - let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); - let uri = format!( - "dashpay://invite?assetlocktx=aa&pk={wif}&display-name=No%20User&avatar-url=https%3A%2F%2Fimg%2Fx.png" - ); - let parsed = parse_invitation_uri(&uri).expect("parse"); - let inviter = parsed.inviter.as_ref().expect("metadata-only inviter"); - assert!(inviter.username.is_none(), "no du ⇒ no username"); - assert_eq!(inviter.display_name.as_deref(), Some("No User")); - assert_eq!(inviter.avatar_url.as_deref(), Some("https://img/x.png")); - } - - /// A duplicated required key is ambiguous (iOS binds the last, Android the - /// first) — reject rather than claim a wallet-dependent key. - #[test] - fn parse_rejects_duplicate_required_keys() { - let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); - let other = PrivateKey::new( - SecretKey::from_secret_bytes([0x44u8; 32]).unwrap(), - Network::Testnet, - ) - .to_wif(); - let dup_pk = format!("dashpay://invite?assetlocktx=aa&pk={wif}&pk={other}"); - assert!(parse_invitation_uri(&dup_pk) - .unwrap_err() - .to_string() - .contains("duplicated")); - - let dup_tx = format!("dashpay://invite?assetlocktx=aa&assetlocktx=bb&pk={wif}"); - assert!(parse_invitation_uri(&dup_tx) - .unwrap_err() - .to_string() - .contains("duplicated")); - } - - /// The scheme/host gate must be anchored: a suffixed scheme host - /// (`dashpay://inviteXYZ`) and a decoy URL that merely *mentions* the applink - /// host in its own query must both be rejected — otherwise the decoy's query - /// (with a `pk`/`assetlocktx`) would be parsed. - #[test] - fn parse_rejects_decoy_scheme_and_host() { - let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); - // Suffixed authority — not exactly `invite`. - let suffixed = format!("dashpay://inviteXYZ?assetlocktx=aa&pk={wif}"); - assert!(parse_invitation_uri(&suffixed).is_err()); - // Decoy host: the real host is evil.example; the applink string only - // appears inside the decoy's own query value. - let decoy = format!( - "https://evil.example/r?next=invitations.dashpay.io/applink&assetlocktx=aa&pk={wif}" - ); - assert!(parse_invitation_uri(&decoy).is_err()); - // Wrong path on the right host. - let wrong_path = format!("https://invitations.dashpay.io/evil?assetlocktx=aa&pk={wif}"); - assert!(parse_invitation_uri(&wrong_path).is_err()); - } - - /// WIF network compatibility is mainnet-vs-not: a testnet WIF is rejected on - /// a mainnet wallet, but accepted on any testnet-family wallet. - #[test] - fn wif_network_matches_is_mainnet_vs_testnet_family() { - assert!(wif_network_matches(Network::Mainnet, Network::Mainnet)); - assert!(wif_network_matches(Network::Testnet, Network::Testnet)); - // WIF decodes 0xEF to Testnet; a devnet/regtest wallet must still accept. - assert!(wif_network_matches(Network::Testnet, Network::Devnet)); - assert!(wif_network_matches(Network::Testnet, Network::Regtest)); - // Cross mainnet/testnet is rejected (the wrong-chain paste). - assert!(!wif_network_matches(Network::Testnet, Network::Mainnet)); - assert!(!wif_network_matches(Network::Mainnet, Network::Testnet)); - } -} diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index 8164e130b0b..dd586fed2f0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -6,7 +6,6 @@ pub mod auto_accept; pub mod contact_info; pub mod dip14; -pub mod invitation; pub mod validation; pub use auto_accept::derive_auto_accept_private_key; @@ -14,12 +13,12 @@ pub use contact_info::{ decode_private_data, derive_contact_info_keys, encode_private_data, ContactInfoKeys, ContactInfoPrivateData, }; +pub use dash_sdk::platform::dashpay::{ + encode_invitation_uri, parse_invitation_uri, voucher_credit_output, voucher_output_index, + wif_network_matches, InviterInfo, ParsedInvitation, +}; pub use dip14::{ calculate_account_reference, derive_contact_payment_address, derive_contact_payment_addresses, derive_contact_xpub, unmask_account_reference, ContactXpubData, DEFAULT_CONTACT_GAP_LIMIT, }; -pub use invitation::{ - encode_invitation_uri, parse_invitation_uri, voucher_credit_output, voucher_output_index, - wif_network_matches, InviterInfo, ParsedInvitation, -}; pub use validation::pubkey_binds_expected_key_data; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index b92ca970f19..b275fe19471 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -15,15 +15,9 @@ use std::collections::BTreeMap; -use dpp::dashcore::consensus::Decodable; -use dpp::dashcore::{InstantLock, Network, OutPoint, PrivateKey, Transaction}; use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::signer::Signer; -use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; -use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; -use dpp::identity::v0::IdentityV0; -use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose, SecurityLevel}; +use dpp::identity::{Identity, IdentityPublicKey, KeyID}; use dpp::prelude::{AssetLockProof, Identifier}; use key_wallet::bip32::{ChildNumber, DerivationPath}; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; @@ -32,80 +26,18 @@ use crate::changeset::{ InvitationChangeSet, InvitationEntry, InvitationStatus, PersistenceCapabilities, }; -use dash_sdk::core::FetchedCoreTransaction; -use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::Fetch; -use crate::error::{ - is_asset_lock_already_consumed, is_instant_lock_proof_invalid, PlatformWalletError, +pub use dash_sdk::platform::dashpay::{ + InvitationClaimStatus, MAX_INVITATION_DUFFS, MAX_INVITATION_TTL_SECS, MIN_INVITATION_DUFFS, }; -use crate::wallet::asset_lock::orchestration::{out_point_from_proof, submit_with_cl_height_retry}; -use crate::wallet::identity::crypto::{ - encode_invitation_uri, voucher_credit_output, wif_network_matches, -}; -use crate::wallet::identity::crypto::{InviterInfo, ParsedInvitation}; + +use crate::error::PlatformWalletError; +use crate::wallet::identity::crypto::{encode_invitation_uri, InviterInfo, ParsedInvitation}; use crate::wallet::identity::network::contact_requests::ContactCryptoProvider; use super::*; -/// Hard cap on the amount an invitation can lock (0.26 DASH). The voucher is a -/// bearer credential, so the blast radius of a leaked link is bounded here in -/// Rust — not just in the UI. Sized for onboarding at BOTH username tiers: the -/// invitee spends the voucher on identity creation **plus** a DPNS name — a -/// normal name (~0.03 DASH, the legacy `DASH_PAY_FEE`) or a contested/premium -/// name (~0.25 DASH, `DASH_PAY_FEE_CONTESTED`), with the 0.01 margin covering -/// the create/claim fees. The earlier 0.05 value deferred the contested tier -/// "until contested-name-via-invite claim exists" — the claim path is -/// amount-agnostic and contested-invite claims are verified working, and the -/// Android wallet has funded contested invitations at 0.25 since 2024, so the -/// deferral is over. (The pre-merge 0.01 iteration was below even a usable -/// non-contested invitation and rejected its own onboarding default.) -pub const MAX_INVITATION_DUFFS: u64 = 26_000_000; - -/// Floor on the amount an invitation can lock (0.003 DASH). A voucher funds a -/// Platform identity operation, and creating an identity — which is what the -/// invitee's claim does, and what a register-target reclaim does — requires the -/// asset lock to carry at least the identity-registration minimum (~0.00228 DASH -/// in credits on the current network; the state transition is rejected with -/// `IdentityAssetLockTransactionOutPointNotEnoughBalanceError` below it). A -/// voucher under this floor produces an invitation that can be neither claimed -/// nor reclaimed, so reject it at creation. Set above the bare floor to leave the -/// new identity a small usable starting balance; tune if the floor changes. -pub const MIN_INVITATION_DUFFS: u64 = 300_000; - -/// Default TTL (24h) for an invitation's advisory expiry. The FFI sets -/// `expiry_unix = now + MAX_INVITATION_TTL_SECS`. The expiry is **advisory** — a -/// leaked-link finder holds the voucher key and ignores it — so it bounds only -/// the honest UI (don't submit an about-to-go-stale IS proof) and the reclaim -/// signal, NOT a leaked-link window. The real leak bound is `MAX_INVITATION_DUFFS`. -pub const MAX_INVITATION_TTL_SECS: u32 = 24 * 60 * 60; - -/// Claim-by-fetch bound: how many times to look up the funding tx (each attempt -/// tries both byte orders) before giving up. InstantSend/ChainLock finality does -/// not guarantee the invitee's DAPI node has indexed the tx yet, so a freshly -/// shared invitation can miss purely from propagation lag; retrying bounds that. -const CLAIM_FETCH_MAX_ATTEMPTS: u32 = 5; - -/// Fixed backoff between claim-by-fetch attempts (matches the identity-create -/// fetch-retry cadence elsewhere in the wallet). 5 attempts × 3s ≈ 12s of -/// tolerance for propagation delay before surfacing a "not found" error. -const CLAIM_FETCH_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(3); - -/// RAII scrub for the voucher `PrivateKey` copy used on the claim path. -/// `dashcore::PrivateKey` wraps a `secp256k1::SecretKey` that has no -/// Drop-zeroize, so the imported bearer scalar would otherwise linger in memory -/// after `claim_invitation` returns on every exit path (success or error). -/// Wiping it here mirrors the create path's explicit scrub of the exported -/// scalar — the voucher key is treated as bearer money end to end. -struct WipingPrivateKey(PrivateKey); - -impl Drop for WipingPrivateKey { - fn drop(&mut self) { - self.0.inner.non_secure_erase(); - } -} - /// A freshly-created invitation: the shareable link plus the bookkeeping the /// inviter tracks to reclaim an unclaimed voucher. pub struct Invitation { @@ -120,25 +52,6 @@ pub struct Invitation { pub expiry_unix: u32, } -/// The invitee's pre-claim view of an invitation, from -/// [`IdentityWallet::invitation_claim_status`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InvitationClaimStatus { - /// The identity the claim would create (derived from the credit outpoint). - pub prospective_identity_id: Identifier, - /// Value of the credit output the voucher key controls (duffs). - pub amount_duffs: u64, - /// The claim would submit an InstantSend proof (the link carried an islock). - pub is_instant: bool, - /// The funding transaction is chain-locked. `false` with `is_instant == - /// false` is a ChainLock-only invitation whose claim must wait for it. - pub is_chain_locked: bool, - /// An identity exists at `prospective_identity_id` — definitively claimed. - /// `false` does NOT prove the voucher is unspent (see - /// [`IdentityWallet::invitation_prospective_identity_id`]). - pub already_claimed: bool, -} - impl std::fmt::Debug for Invitation { /// Redacts the URI — it embeds the bearer voucher key. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -151,37 +64,6 @@ impl std::fmt::Debug for Invitation { } } -/// Pre-flight the caller-supplied identity keys map: id=0 must be a MASTER-level -/// AUTHENTICATION key (it signs the IdentityCreate transition). Mirrors -/// `register_identity_with_funding`. -fn preflight_keys_map( - keys_map: &BTreeMap, -) -> Result<(), PlatformWalletError> { - if keys_map.is_empty() { - return Err(PlatformWalletError::InvalidIdentityData( - "keys_map must contain at least one identity public key".to_string(), - )); - } - match keys_map.get(&0) { - Some(k) - if k.security_level() == SecurityLevel::MASTER - && k.purpose() == Purpose::AUTHENTICATION => {} - Some(_) => { - return Err(PlatformWalletError::InvalidIdentityData( - "keys_map[0] must be a MASTER-level AUTHENTICATION key \ - (required to sign the IdentityCreate transition)" - .to_string(), - )) - } - None => { - return Err(PlatformWalletError::InvalidIdentityData( - "keys_map must include key id=0 with MASTER security level".to_string(), - )) - } - } - Ok(()) -} - /// Extract the u32 funding index from an invitation funding path's tail. /// /// The invitation funding address is drawn from the account's address pool, so @@ -200,20 +82,6 @@ fn funding_index_from_path(path: &DerivationPath) -> Option { } } -/// Byte-reverse a txid hex string. Old iOS invitation links carry the funding -/// txid in little-endian internal order; DAPI keys `getTransaction` by the -/// big-endian display id, so the claim retries with the id reversed on a miss -/// (mirrors Android's `Sha256Hash.wrap(id).reversedBytes` retry). -fn reverse_txid_hex(txid_hex: &str) -> Result { - let mut bytes = hex::decode(txid_hex).map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "invitation funding txid is not valid hex: {e}" - )) - })?; - bytes.reverse(); - Ok(hex::encode(bytes)) -} - impl IdentityWallet { /// Create a DashPay invitation: fund a one-time asset-lock voucher at the /// DIP-13 invitation path and return a shareable `dashpay://invite` link. @@ -411,120 +279,36 @@ impl IdentityWallet { } /// The identity id this invitation WOULD create, without claiming it. - /// - /// Platform derives a created identity's id from the asset-lock outpoint, - /// so the id is knowable before the claim. The claim is otherwise the only - /// way to learn a voucher is spent, which is why a used invitation surfaces - /// as a raw "asset lock … already completely used" after the invitee has - /// picked a username and entered their PIN. - /// - /// # Detects claims, not consumption — the signal is ONE-WAY - /// - /// An identity existing under the returned id means the voucher was - /// **definitely** claimed. Its absence does **not** mean the voucher is - /// usable. - /// - /// The lock can also be consumed by `IdentityTopUp` — the reclaim path - /// behind `platform_wallet_topup_identity_with_existing_asset_lock_signer` - /// with `consume_invitation_voucher: true` — which credits an EXISTING - /// identity rather than creating the derived one. Afterwards no identity - /// exists at this id, yet a claim still fails deterministically because the - /// asset-lock output is already spent. - /// - /// Platform exposes no client query for spent asset locks (drive tracks - /// them under `SpentAssetLockTransactions`, but no DAPI endpoint surfaces - /// it), so consumption cannot be checked from here. Callers must therefore - /// treat this as a fast-fail for the common case only: reject the - /// invitation when an identity exists, and otherwise proceed WITHOUT - /// concluding the voucher is usable. It narrows when the late failure - /// happens; it does not remove it. - /// - /// Costs one funding-tx fetch: the outpoint is not in the link (the credit - /// output is selected by pk↔script match, not by index), so the tx has to - /// be refetched exactly as the claim does. Same wrong-network fail-fast as - /// [`Self::claim_invitation`], so a testnet link on mainnet reports the - /// network mismatch rather than a confusing fetch miss. + /// Forwards to [`dash_sdk::Sdk::invitation_prospective_identity_id`]; see + /// there for the one-way caveat (an identity at the id proves a claim, its + /// absence does not prove the voucher is unspent). pub async fn invitation_prospective_identity_id( &self, invitation: &ParsedInvitation, ) -> Result { - let funding = self.inspect_invitation_funding(invitation).await?; - Ok(identity_id_for_out_point(funding.out_point)) + Ok(self + .sdk + .invitation_prospective_identity_id(invitation) + .await?) } - /// What an invitation is worth and whether it was already claimed — the - /// invitee's pre-claim check, one network round trip before any username - /// is picked. - /// - /// The amount is the tier signal: the link does not say whether it funds a - /// contested or a non-contested username, the value of the voucher's credit - /// output does (the inviter chose it). It is read from the same refetched - /// funding transaction the claim uses, so it is exactly what the claim - /// would spend. - /// - /// `already_claimed` carries the one-way caveat of - /// [`Self::invitation_prospective_identity_id`]: `true` is definitive, - /// `false` does not prove the voucher is unspent (a reclaim top-up consumes - /// it without creating the derived identity). - /// - /// It describes the funding transaction rather than requiring a buildable - /// proof: a ChainLock-only link whose funding tx is not chain-locked yet - /// reports `is_instant == false, is_chain_locked == false` and its amount - /// instead of failing (the claim of such a link waits for the ChainLock). - /// A failed identity lookup still fails the whole call. - /// - /// Costs one funding-tx fetch (with the claim's bounded propagation retry) - /// and one identity fetch. Same wrong-network fail-fast as the claim. + /// What an invitation is worth and whether it was already claimed. + /// Forwards to [`dash_sdk::Sdk::invitation_claim_status`]. pub async fn invitation_claim_status( &self, invitation: &ParsedInvitation, ) -> Result { - let funding = self.inspect_invitation_funding(invitation).await?; - let prospective_identity_id = identity_id_for_out_point(funding.out_point); - let already_claimed = Identity::fetch(&self.sdk, prospective_identity_id) - .await - .map_err(PlatformWalletError::Sdk)? - .is_some(); - Ok(InvitationClaimStatus { - prospective_identity_id, - amount_duffs: funding.credit_output_duffs, - is_instant: funding.instant_lock.is_some(), - is_chain_locked: funding.is_chain_locked, - already_claimed, - }) + Ok(self.sdk.invitation_claim_status(invitation).await?) } /// Claim a DashPay invitation: register a NEW identity for the invitee, - /// funded by the imported voucher. - /// - /// The link carries only the voucher key + funding txid (+ optional islock), - /// not the funding proof — so this **refetches** the funding transaction - /// from Core and reconstructs the asset-lock proof, mirroring the legacy - /// Android claim (`TopUpRepository.obtainAssetLockTransaction`): - /// 1. Fetch the tx by `funding_txid`; retry byte-reversed on a miss (old iOS - /// links are little-endian). - /// 2. Fail-fast that the fetched tx is really the funding tx, and (if an - /// islock is present) that the islock locks it. - /// 3. Select the funded credit output by pk↔script match (not index 0). - /// 4. Build an `InstantAssetLockProof` when an islock is present (the fast - /// path), else a `ChainAssetLockProof` once the funding tx is chain-locked. - /// - /// The IS proof is the *fast* path only: a voucher islock is signed at - /// creation, so by the time the invitee claims (minutes-to-hours later, and on - /// testnet possibly across a platform-4.1 quorum rotation) Drive may reject the - /// islock as stale with `InvalidInstantAssetLockProofSignatureError` — its own - /// message asks to "try chain asset lock proof instead". When the funding tx is - /// already chain-locked (the usual case by claim time), this resubmits a - /// `ChainAssetLockProof` over the SAME credit output, mirroring the IS→CL - /// fallback the register/top-up paths use. If the tx is not yet chain-locked no - /// fallback is possible and the claim surfaces a retry signal. + /// funded by the imported voucher, then record it in this wallet. /// - /// The invitee's own identity keys (`keys_map`, derived from the invitee's - /// seed) are signed by `identity_signer`; the asset-lock's outer - /// state-transition signature is produced from the **imported voucher key** - /// (`invitation.voucher_key`) via the SDK's in-process raw-key path. The - /// invitee owns neither the lock's inputs nor its tracking, so this bypasses - /// the wallet's `AssetLockFunding` machinery entirely. + /// The claim itself — refetching the funding transaction, rebuilding the + /// asset-lock proof, the stale-islock → ChainLock fallback and adopting an + /// earlier claim that already landed — is + /// [`dash_sdk::Sdk::claim_invitation`]. This adds the identity and its + /// keys to the wallet's identity manager at `identity_index`. /// /// The contact-bootstrap is a separate step: on success the UI asks the /// invitee whether to establish contact with the sender and, if so, calls @@ -540,81 +324,9 @@ impl IdentityWallet { where S: Signer + Send + Sync, { - preflight_keys_map(&keys_map)?; - ensure_invitation_network(&invitation, self.sdk.network)?; - - // Reconstruct the funding asset-lock proof by refetching the tx. Consensus - // enforces pk↔output, islock↔tx, and identity_id↔outpoint, so the local - // guards below are for fast-fail + correct-index selection, not theft - // prevention (a crafted link at worst yields a failed claim). - // - // `primary` is submitted first: an InstantSend proof when the link carried - // an islock (the fast path), or a ChainLock proof when it did not. - // `chain_fallback` is a ChainLock proof over the SAME credit output, - // populated only when `primary` is InstantSend AND the funding tx is - // already chain-locked — the stale-islock recovery below resubmits it. - let ReconstructedProof { - primary, - chain_fallback, - } = self.reconstruct_asset_lock_proof(&invitation).await?; - // Taken before `primary` moves into the submission: the outpoint this - // claim spends and the identity it creates, for recognizing our own - // earlier claim below. - let out_point = out_point_from_proof(&primary); - let prospective_identity_id = identity_id_for_out_point(out_point); - - // The voucher key signs the asset lock's outer ST signature (ECDSA over - // the credit-output pubkey hash). Convert to the SDK's `PrivateKey`, - // scrubbed on every exit path by the `WipingPrivateKey` guard. - let network = self.sdk.network; - let voucher_priv = WipingPrivateKey(PrivateKey::new(invitation.voucher_key, network)); - - // Build the placeholder identity ONCE so both the primary attempt and the - // IS→CL fallback submit the same key set without a `keys_map` clone. - let placeholder = Identity::V0(IdentityV0 { - id: Identifier::default(), - public_keys: keys_map, - balance: 0, - revision: 0, - }); - - // Submit through the stale-islock fallback seam: the primary proof - // first and — only on Platform's stale-islock rejection — the ChainLock - // fallback over the SAME credit output (or `AssetLockNotChainLocked` - // when none could be built). Each submission is wrapped in the shared - // CL-height-too-low retry (Platform's observed Core tip briefly behind - // the proof's chain-locked height — the same transient the - // register/top-up paths absorb; harmless for an IS proof, which never - // triggers it). This mirrors `register_identity_with_funding`'s IS→CL - // fallback, except the CL proof was rebuilt from the refetched tx: the - // invitee tracks no asset lock of its own, so there is no - // `upgrade_to_chain_lock_proof` to call. - let sdk = &self.sdk; - let placeholder = &placeholder; - let voucher_priv = &voucher_priv; - let submitted = - submit_claim_with_stale_islock_fallback(primary, chain_fallback, move |proof| { - submit_with_cl_height_retry(settings, move |s| { - placeholder.put_to_platform_and_wait_for_response_with_private_key( - sdk, - proof.clone(), - &voucher_priv.0, - identity_signer, - s, - ) - }) - }) - .await; - - // A lost-ACK duplicate of this very submission ("already in the - // mempool / in chain") is resolved inside the SDK's result wait. What - // reaches here is a claim repeated after an earlier one landed, refused - // because its outpoint is consumed: adopt the identity when it carries - // exactly the keys we submitted. - let identity = - adopt_already_landed_claim(submitted, &out_point, placeholder.public_keys(), || { - Identity::fetch(sdk, prospective_identity_id) - }) + let identity = self + .sdk + .claim_invitation(invitation, keys_map, identity_signer, settings) .await?; // Best-effort local bookkeeping — Platform has already accepted the @@ -680,413 +392,6 @@ impl IdentityWallet { Ok(identity) } - - /// Refetch the funding transaction and rebuild its asset-lock proof. - /// - /// Mirrors Android `TopUpRepository.obtainAssetLockTransaction`: fetch by - /// txid (retry byte-reversed on a miss), verify the fetched tx is the funding - /// tx, select the voucher's credit output, and assemble an InstantSend proof - /// (when the link carried an islock) or a ChainLock proof (islock absent / - /// `"null"` — a chainlock-confirmed invite). - /// - /// Returns a [`ReconstructedProof`]: the `primary` proof to submit plus an - /// optional `chain_fallback` ChainLock proof used by the caller's stale-islock - /// recovery (populated only when the primary is InstantSend and the funding tx - /// is already chain-locked). - async fn reconstruct_asset_lock_proof( - &self, - invitation: &ParsedInvitation, - ) -> Result { - let fetched = self.fetch_funding_tx(invitation).await?; - assemble_asset_lock_proof( - fetched.transaction, - fetched.is_chain_locked, - fetched.height, - invitation, - ) - } - - /// The wrong-network guard, the funding-tx fetch and its inspection: what - /// the invitee's read-only queries need, without requiring the tx to be - /// claimable yet (see [`inspect_funding_tx`]). - async fn inspect_invitation_funding( - &self, - invitation: &ParsedInvitation, - ) -> Result { - ensure_invitation_network(invitation, self.sdk.network)?; - let fetched = self.fetch_funding_tx(invitation).await?; - inspect_funding_tx(&fetched.transaction, fetched.is_chain_locked, invitation) - } - - /// Fetch the invitation's funding transaction with the bounded propagation - /// retry (see [`fetch_funding_tx_with_retry`]); a miss after every attempt - /// is an error. - async fn fetch_funding_tx( - &self, - invitation: &ParsedInvitation, - ) -> Result { - let sdk = &self.sdk; - fetch_funding_tx_with_retry( - &invitation.funding_txid, - |txid| async move { - sdk.get_transaction(&txid) - .await - .map_err(PlatformWalletError::Sdk) - }, - || tokio::time::sleep(CLAIM_FETCH_RETRY_DELAY), - ) - .await? - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "invitation funding transaction not found (tried both byte orders across \ - repeated attempts); it may not have propagated to the queried DAPI node yet — \ - retry shortly" - .to_string(), - ) - }) - } -} - -/// Fetch the claim's funding transaction with the bounded propagation retry — -/// the injectable orchestration seam of `reconstruct_asset_lock_proof` (tests -/// script `fetch`/`delay`; production passes `Sdk::get_transaction` and a -/// `tokio::time::sleep`). -/// -/// Two independent concerns are layered here: -/// 1. Byte order — old iOS links carry the txid little-endian, so a -/// canonical miss is retried byte-reversed **within the same attempt** -/// (a compatibility fallback, not a temporal retry). A transport error is -/// NOT masked as a miss — it propagates immediately (it is not a "not -/// indexed yet" signal, so retrying it would only hide it). -/// 2. Propagation lag — InstantSend/ChainLock finality does not guarantee -/// the queried DAPI node has indexed the tx yet, so a fresh invitation -/// can miss on both byte orders purely from propagation delay. Retry up -/// to [`CLAIM_FETCH_MAX_ATTEMPTS`] attempts with one `delay` between -/// attempts — and none after the last (the caller surfaces the miss -/// immediately). -/// -/// Returns `Ok(None)` only after every attempt missed on both byte orders. -async fn fetch_funding_tx_with_retry( - funding_txid: &str, - mut fetch: F, - mut delay: D, -) -> Result, PlatformWalletError> -where - F: FnMut(String) -> FFut, - FFut: std::future::Future, PlatformWalletError>>, - D: FnMut() -> DFut, - DFut: std::future::Future, -{ - for attempt in 0..CLAIM_FETCH_MAX_ATTEMPTS { - if let Some(tx) = fetch(funding_txid.to_string()).await? { - return Ok(Some(tx)); - } - // Reversed lookup is computed lazily on a canonical miss, so a hit - // never depends on the txid being reversible hex. - let reversed = reverse_txid_hex(funding_txid)?; - if let Some(tx) = fetch(reversed).await? { - return Ok(Some(tx)); - } - if attempt + 1 < CLAIM_FETCH_MAX_ATTEMPTS { - delay().await; - } - } - Ok(None) -} - -/// Submit the claim's reconstructed proof with the stale-islock → ChainLock -/// fallback — the injectable orchestration seam of the claim submission (tests -/// script `submit`; production passes the placeholder-identity put wrapped in -/// `submit_with_cl_height_retry`). -/// -/// `primary` is submitted first. Exactly ONE recovery is attempted, and only -/// for the one rejection that indicts the proof kind rather than the claim: -/// `is_instant_lock_proof_invalid` (Platform rejected a stale islock — its -/// signing quorum rotated out, or it is no longer "recent"; Platform's own -/// message asks us to "try chain asset lock proof instead"). On that rejection -/// the ChainLock `chain_fallback` over the SAME credit output is submitted — -/// or, when the funding tx was not yet chain-locked so no fallback could be -/// built, `AssetLockNotChainLocked` surfaces a clear retry signal instead of -/// the raw consensus error. Every other error — from the primary or from the -/// fallback itself — propagates as [`PlatformWalletError::Sdk`] with no -/// further submission. -async fn submit_claim_with_stale_islock_fallback( - primary: AssetLockProof, - chain_fallback: Option, - mut submit: F, -) -> Result -where - F: FnMut(AssetLockProof) -> Fut, - Fut: std::future::Future>, -{ - match submit(primary).await { - Ok(created) => Ok(created), - Err(e) if is_instant_lock_proof_invalid(&e) => { - let Some(chain_proof) = chain_fallback else { - return Err(PlatformWalletError::AssetLockNotChainLocked( - "invitation islock proof was rejected by Platform (stale — quorum \ - rotated or no longer recent) and the funding transaction is not yet \ - chain-locked, so no ChainLock fallback is possible; retry once the \ - funding block is chain-locked" - .to_string(), - )); - }; - tracing::warn!( - "invitation IS-lock proof rejected by Platform on claim; retrying with a \ - ChainLock proof over the same funding outpoint" - ); - submit(chain_proof).await.map_err(PlatformWalletError::Sdk) - } - Err(e) => Err(PlatformWalletError::Sdk(e)), - } -} - -/// Refuse a link whose voucher key is for the other network before any -/// network work: a testnet WIF is a valid key on the wrong chain, so it would -/// otherwise surface as a confusing funding-tx fetch miss. -fn ensure_invitation_network( - invitation: &ParsedInvitation, - wallet_network: Network, -) -> Result<(), PlatformWalletError> { - if wif_network_matches(invitation.voucher_key_network, wallet_network) { - Ok(()) - } else { - Err(PlatformWalletError::InvitationNetworkMismatch { - invitation: invitation.voucher_key_network, - wallet: wallet_network, - }) - } -} - -/// The identity id Platform derives from an asset-lock outpoint. Both proof -/// kinds hash only the outpoint, so the ChainLock derivation (which ignores -/// its height) gives the id for either. -fn identity_id_for_out_point(out_point: OutPoint) -> Identifier { - ChainAssetLockProof::new(0, out_point.into()).create_identifier() -} - -/// Recognize a claim Platform already executed. A claim repeated after an -/// earlier attempt landed (reported as a failure, or interrupted) is refused -/// because its outpoint is consumed; the identity at the prospective id is -/// ours when it carries exactly the keys we submitted, and is then the -/// claim's result. Anything else (no identity, someone else's, a failed -/// lookup) reports the original rejection. -/// -/// `fetch` looks up the identity at the prospective id: the injectable seam -/// (production passes `Identity::fetch`). -async fn adopt_already_landed_claim( - submitted: Result, - out_point: &OutPoint, - submitted_keys: &BTreeMap, - fetch: F, -) -> Result -where - F: FnOnce() -> Fut, - Fut: std::future::Future, dash_sdk::Error>>, -{ - let error = match submitted { - Err(PlatformWalletError::Sdk(error)) - if is_asset_lock_already_consumed(&error, out_point) => - { - error - } - other => return other, - }; - match fetch().await { - Ok(Some(identity)) if identity_carries_keys(&identity, submitted_keys) => { - tracing::info!( - identity_id = %identity.id(), - "invitation claim was already accepted by Platform; adopting its identity" - ); - Ok(identity) - } - Ok(_) => Err(PlatformWalletError::Sdk(error)), - Err(lookup) => { - tracing::warn!( - error = %lookup, - "could not look up the identity of a consumed invitation; reporting the rejection" - ); - Err(PlatformWalletError::Sdk(error)) - } - } -} - -/// The identity carries exactly `expected` public keys — same ids, same key -/// data. Key data is what only our signer could have produced, so a match -/// means the identity was created by our claim, not by a finder of the link. -fn identity_carries_keys( - identity: &Identity, - expected: &BTreeMap, -) -> bool { - let actual = identity.public_keys(); - actual.len() == expected.len() - && expected.iter().all(|(id, key)| { - actual.get(id).is_some_and(|found| { - found.data() == key.data() && found.key_type() == key.key_type() - }) - }) -} - -/// The claim's reconstructed funding proof, plus an optional ChainLock fallback. -/// -/// `primary` is submitted first: an [`AssetLockProof::Instant`] when the link -/// carried an islock (the fast path), or an [`AssetLockProof::Chain`] when it did -/// not. `chain_fallback` is a ChainLock proof over the SAME credit output, -/// populated ONLY when `primary` is InstantSend AND the funding tx is already -/// chain-locked — [`IdentityWallet::claim_invitation`]'s stale-islock recovery -/// resubmits it if Platform rejects the primary IS proof with -/// `InvalidInstantAssetLockProofSignatureError`. It is `None` when the primary is -/// already a ChainLock proof (nothing to fall back to) or when the funding tx is -/// not yet chain-locked (no ChainLock proof can be built — the claim must be -/// retried once the block confirms). -#[derive(Debug)] -struct ReconstructedProof { - primary: AssetLockProof, - chain_fallback: Option, -} - -/// What an invitation's funding transaction says before any proof is built. -#[derive(Debug)] -struct InvitationFunding { - /// The voucher's credit outpoint; the claim's identity id derives from it. - out_point: OutPoint, - /// Value of that credit output (duffs): what the invitation is worth. - credit_output_duffs: u64, - /// The link's islock, decoded and checked to lock this transaction. - instant_lock: Option, - /// The funding transaction is chain-locked. - is_chain_locked: bool, -} - -/// Check a fetched funding transaction against the link and select the -/// voucher's credit output: the pure core shared by the claim's proof -/// assembly and the read-only queries. Validates the tx is the funding tx -/// (either byte order), selects the voucher's credit output, and decodes the -/// link's islock (checking it locks this tx). Chain-lock finality is reported, -/// not required: only building a ChainLock proof needs it. -fn inspect_funding_tx( - transaction: &Transaction, - is_chain_locked: bool, - invitation: &ParsedInvitation, -) -> Result { - // Fail-fast: the fetched tx must actually be the funding tx (either byte - // order). DAPI returns whatever tx matches the id we asked for, so this - // guards a backend that answers with an unrelated tx. - let fetched_txid = transaction.txid().to_string(); - let reversed_txid = reverse_txid_hex(&invitation.funding_txid).ok(); - if fetched_txid != invitation.funding_txid - && reversed_txid.as_deref() != Some(fetched_txid.as_str()) - { - return Err(PlatformWalletError::InvalidIdentityData( - "fetched transaction id does not match the invitation funding txid".to_string(), - )); - } - - // Select the funded credit output the voucher key controls (not index 0 - // — a legacy invite's credit output need not be first). - let (output_index, credit_output) = - voucher_credit_output(transaction, &invitation.voucher_key)?; - - let instant_lock = match &invitation.islock_hex { - Some(islock_hex) => { - let islock_bytes = hex::decode(islock_hex).map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "invitation islock is not valid hex: {e}" - )) - })?; - // The hex is not self-describing; the modern deterministic islock - // (ISDLOCK) carries its version byte, which is exactly what the - // consensus decoder reads first. - let instant_lock = InstantLock::consensus_decode(&mut islock_bytes.as_slice()) - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "invitation islock could not be decoded: {e}" - )) - })?; - if instant_lock.txid != transaction.txid() { - return Err(PlatformWalletError::InvalidIdentityData( - "invitation islock does not lock the funding transaction".to_string(), - )); - } - Some(instant_lock) - } - None => None, - }; - - Ok(InvitationFunding { - out_point: OutPoint::new(transaction.txid(), output_index), - credit_output_duffs: credit_output.value, - instant_lock, - is_chain_locked, - }) -} - -/// Assemble the asset-lock proof from an already-fetched funding transaction — the -/// pure, testable core of the claim reconstruction (the fetch/retry lives in -/// `reconstruct_asset_lock_proof`). Checks the tx with [`inspect_funding_tx`], then -/// builds an InstantSend proof (link carried an islock) or a ChainLock proof -/// (islock absent), requiring chain-lock finality for the latter. -/// -/// When an islock is present AND the funding tx is already chain-locked, the -/// returned [`ReconstructedProof`] also carries a `chain_fallback` ChainLock proof -/// over the same credit output, so the caller can recover from a stale islock that -/// Platform rejects without refetching the tx. -fn assemble_asset_lock_proof( - transaction: Transaction, - is_chain_locked: bool, - height: u32, - invitation: &ParsedInvitation, -) -> Result { - let InvitationFunding { - out_point, - instant_lock, - .. - } = inspect_funding_tx(&transaction, is_chain_locked, invitation)?; - - // A ChainLock proof over the selected credit output. Buildable only once the - // funding block is chain-locked; `height` is the tx's mined height (the - // `ChainAssetLockProof`'s `core_chain_locked_height`). Reused both as the - // primary for an islock-less invite and as the stale-islock fallback. - let chain_lock_proof = - || AssetLockProof::Chain(ChainAssetLockProof::new(height, out_point.into())); - - match instant_lock { - Some(instant_lock) => { - // Fast path: submit the InstantSend proof. If the islock is stale - // (quorum rotated / no longer "recent") Platform rejects it, and the - // claim falls back to `chain_fallback` — available only when the - // funding tx is already chain-locked (the usual case by claim time, - // since the voucher was funded minutes-to-hours earlier). - let chain_fallback = is_chain_locked.then(chain_lock_proof); - let primary = AssetLockProof::Instant(InstantAssetLockProof::new( - instant_lock, - transaction, - out_point.vout, - )); - Ok(ReconstructedProof { - primary, - chain_fallback, - }) - } - None => { - // ChainLock invite: the proof references the outpoint + a chain-locked - // core height. Require the funding tx to be chain-locked so the proof - // proves finality; the inviter/invitee retries once the block is - // chain-locked otherwise. There is no separate fallback — this IS the - // ChainLock proof. - if !is_chain_locked { - return Err(PlatformWalletError::InvalidIdentityData( - "chainlock invitation funding transaction is not yet chain-locked; \ - retry once it confirms" - .to_string(), - )); - } - Ok(ReconstructedProof { - primary: chain_lock_proof(), - chain_fallback: None, - }) - } - } } #[cfg(test)] @@ -1136,812 +441,6 @@ mod tests { assert_eq!(funding_index_from_path(&path), None); } - // --- assemble_asset_lock_proof: the claim-time proof reconstruction guards --- - - use crate::wallet::identity::crypto::invitation::{voucher_credit_script, ParsedInvitation}; - use dpp::dashcore::consensus::Encodable; - use dpp::dashcore::secp256k1::SecretKey; - use dpp::dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; - use dpp::dashcore::transaction::special_transaction::TransactionPayload; - use dpp::dashcore::{Network, TxOut}; - - fn voucher_secret() -> SecretKey { - SecretKey::from_secret_bytes([0x11u8; 32]).unwrap() - } - - /// An asset-lock tx whose single credit output pays the voucher key. - fn funding_tx(key: &SecretKey) -> Transaction { - let payload = AssetLockPayload { - version: 1, - credit_outputs: vec![TxOut { - value: 100_000, - script_pubkey: voucher_credit_script(key), - }], - }; - Transaction { - version: 3, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), - } - } - - fn parsed( - key: SecretKey, - funding_txid: String, - islock_hex: Option, - ) -> ParsedInvitation { - ParsedInvitation { - voucher_key: key, - voucher_key_network: Network::Testnet, - funding_txid, - islock_hex, - inviter: None, - } - } - - /// A backend answering with an unrelated tx (id matches neither byte order) is - /// rejected before any proof is built. - #[test] - fn assemble_rejects_txid_mismatch() { - let key = voucher_secret(); - let tx = funding_tx(&key); - let inv = parsed(key, "00".repeat(32), None); - let err = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap_err(); - assert!(format!("{err}").contains("does not match")); - } - - /// A ChainLock invite (no islock) whose funding tx is not yet chain-locked is - /// rejected — the proof must prove finality. - #[test] - fn assemble_chainlock_requires_chain_lock() { - let key = voucher_secret(); - let tx = funding_tx(&key); - let txid = tx.txid().to_string(); - let inv = parsed(key, txid, None); - let err = assemble_asset_lock_proof(tx, false, 100, &inv).unwrap_err(); - assert!(format!("{err}").contains("not yet chain-locked")); - } - - /// A chain-locked ChainLock invite (no islock) assembles a ChainLock proof at - /// the tx's voucher output, with no separate fallback (the primary IS the CL - /// proof). - #[test] - fn assemble_chainlock_ok_when_locked() { - let key = voucher_secret(); - let tx = funding_tx(&key); - let txid = tx.txid().to_string(); - let inv = parsed(key, txid, None); - let reconstructed = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); - assert!(matches!(reconstructed.primary, AssetLockProof::Chain(_))); - assert!( - reconstructed.chain_fallback.is_none(), - "an islock-less chainlock invite has no separate fallback" - ); - } - - /// An islock that locks the funding tx, with the tx already chain-locked, - /// yields an InstantSend primary (fast path) PLUS a ChainLock fallback over the - /// same credit output — the stale-islock recovery the claim path submits if - /// Platform rejects the IS proof with `InvalidInstantAssetLockProofSignatureError`. - #[test] - fn assemble_islock_present_and_chainlocked_carries_chain_fallback() { - let key = voucher_secret(); - let tx = funding_tx(&key); - let txid = tx.txid().to_string(); - let mut islock = InstantLock::default(); - islock.txid = tx.txid(); - let mut islock_bytes = Vec::new(); - islock.consensus_encode(&mut islock_bytes).unwrap(); - let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); - let reconstructed = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); - assert!( - matches!(reconstructed.primary, AssetLockProof::Instant(_)), - "the islock fast path must be tried first" - ); - assert!( - matches!(reconstructed.chain_fallback, Some(AssetLockProof::Chain(_))), - "a chain-locked funding tx must carry a ChainLock fallback for stale-islock recovery" - ); - } - - /// An islock that locks the funding tx, but the tx NOT yet chain-locked, yields - /// the IS primary with NO fallback: if that islock is later rejected there is - /// no ChainLock proof to fall back to, and the claim must be retried once the - /// block confirms. - #[test] - fn assemble_islock_present_not_chainlocked_has_no_fallback() { - let key = voucher_secret(); - let tx = funding_tx(&key); - let txid = tx.txid().to_string(); - let mut islock = InstantLock::default(); - islock.txid = tx.txid(); - let mut islock_bytes = Vec::new(); - islock.consensus_encode(&mut islock_bytes).unwrap(); - let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); - let reconstructed = assemble_asset_lock_proof(tx, false, 100, &inv).unwrap(); - assert!(matches!(reconstructed.primary, AssetLockProof::Instant(_))); - assert!( - reconstructed.chain_fallback.is_none(), - "an un-chain-locked funding tx cannot produce a ChainLock fallback" - ); - } - - /// The prospective identity id is derived from the *selected* credit - /// output's outpoint. Selection itself is pinned next to - /// `voucher_output_index`; what matters here is that the id follows it — a - /// voucher sitting behind a decoy must not yield the index-0 id, or the - /// "has this invitation been used?" check answers about a stranger's - /// identity and reports a perfectly good voucher as spent. - #[test] - fn prospective_id_follows_the_selected_credit_output() { - let key = voucher_secret(); - let decoy = SecretKey::from_secret_bytes([0x22u8; 32]).unwrap(); - let payload = AssetLockPayload { - version: 1, - credit_outputs: vec![ - TxOut { - value: 100_000, - script_pubkey: voucher_credit_script(&decoy), - }, - TxOut { - value: 25_000_000, - script_pubkey: voucher_credit_script(&key), - }, - ], - }; - let tx = Transaction { - version: 3, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), - }; - let txid = tx.txid(); - let inv = parsed(key, txid.to_string(), None); - - let funding = inspect_funding_tx(&tx, true, &inv).unwrap(); - assert_eq!( - funding.credit_output_duffs, 25_000_000, - "the amount must come from the voucher's output, not the decoy at index 0" - ); - let proof = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); - let id = proof.primary.create_identifier().unwrap(); - assert_eq!( - identity_id_for_out_point(funding.out_point), - id, - "the read-only queries must derive the id the claim creates" - ); - - let from_index_0 = - ChainAssetLockProof::new(100, OutPoint::new(txid, 0).into()).create_identifier(); - let from_index_1 = - ChainAssetLockProof::new(100, OutPoint::new(txid, 1).into()).create_identifier(); - - assert_ne!(id, from_index_0, "id must not come from credit output 0"); - assert_eq!(id, from_index_1); - } - - /// The prospective id is a pure function of the asset-lock OUTPOINT, so it - /// carries no information about whether that lock has been consumed. - /// - /// This is why the claimed-check is one-way. A voucher claimed normally - /// creates the identity at this id, and the check sees it. A voucher - /// RECLAIMED into an existing identity — `IdentityTopUp` via - /// `platform_wallet_topup_identity_with_existing_asset_lock_signer` with - /// `consume_invitation_voucher: true` — spends the very same outpoint but - /// creates nothing here, so the check still finds no identity while a claim - /// would fail deterministically. - /// - /// Pinned as an executable fact because the id derivation is what a reader - /// would otherwise assume encodes "spent": it does not, and Platform - /// exposes no client query for spent asset locks to fill the gap. - #[test] - fn prospective_id_is_outpoint_derived_and_says_nothing_about_consumption() { - let key = voucher_secret(); - let payload = AssetLockPayload { - version: 1, - credit_outputs: vec![TxOut { - value: 100_000, - script_pubkey: voucher_credit_script(&key), - }], - }; - let tx = Transaction { - version: 3, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), - }; - let txid = tx.txid(); - let inv = parsed(key, txid.to_string(), None); - - let id = assemble_asset_lock_proof(tx, true, 100, &inv) - .unwrap() - .primary - .create_identifier() - .unwrap(); - - // Nothing but (txid, vout) feeds it — the same value a reclaim would - // leave behind untouched. - let from_outpoint = - ChainAssetLockProof::new(100, OutPoint::new(txid, 0).into()).create_identifier(); - assert_eq!( - id, from_outpoint, - "the id must be derivable from the outpoint alone, which is exactly \ - why its absence cannot prove the lock is unspent" - ); - } - - /// A test identity key: `byte` fills the key data, so two keys differ - /// exactly when their bytes do. - fn test_key(id: KeyID, byte: u8) -> IdentityPublicKey { - use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - use dpp::identity::KeyType; - use dpp::platform_value::BinaryData; - - IdentityPublicKey::V0(IdentityPublicKeyV0 { - id, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::MASTER, - contract_bounds: None, - key_type: KeyType::ECDSA_SECP256K1, - read_only: false, - data: BinaryData::new(vec![byte; 33]), - disabled_at: None, - }) - } - - fn identity_with_keys(keys: BTreeMap) -> Identity { - Identity::V0(IdentityV0 { - id: Identifier::default(), - public_keys: keys, - balance: 0, - revision: 0, - }) - } - - fn our_keys() -> BTreeMap { - [(0, test_key(0, 1)), (1, test_key(1, 2))].into() - } - - /// A claim that already landed is recognized as ours only by its keys: - /// the same key set is ours, anything else (a finder of the link claiming - /// first) is not. - #[test] - fn should_recognize_a_claimed_identity_only_by_our_keys() { - let ours = our_keys(); - assert!(identity_carries_keys( - &identity_with_keys(ours.clone()), - &ours - )); - let theirs: BTreeMap = - [(0, test_key(0, 9)), (1, test_key(1, 2))].into(); - assert!(!identity_carries_keys(&identity_with_keys(theirs), &ours)); - let fewer: BTreeMap = [(0, test_key(0, 1))].into(); - assert!(!identity_carries_keys(&identity_with_keys(fewer), &ours)); - } - - /// A testnet link in a mainnet wallet is refused with the typed mismatch - /// the FFI maps to `ErrorInvalidNetwork`. - #[test] - fn should_refuse_an_invitation_for_the_other_network() { - let inv = parsed(voucher_secret(), "00".repeat(32), None); - assert!(ensure_invitation_network(&inv, Network::Testnet).is_ok()); - assert!(matches!( - ensure_invitation_network(&inv, Network::Mainnet), - Err(PlatformWalletError::InvitationNetworkMismatch { - invitation: Network::Testnet, - wallet: Network::Mainnet, - }) - )); - } - - /// A ChainLock-only link whose funding tx is not chain-locked yet cannot - /// be claimed, but the read-only view still reads its amount and outpoint - /// and reports the missing ChainLock instead of failing. - #[test] - fn should_inspect_a_chainlock_invitation_before_it_is_chain_locked() { - let key = voucher_secret(); - let tx = funding_tx(&key); - let txid = tx.txid(); - let inv = parsed(key, txid.to_string(), None); - - let funding = inspect_funding_tx(&tx, false, &inv).unwrap(); - assert_eq!(funding.credit_output_duffs, 100_000); - assert_eq!(funding.out_point, OutPoint::new(txid, 0)); - assert!(funding.instant_lock.is_none()); - assert!(!funding.is_chain_locked); - assert!( - assemble_asset_lock_proof(tx, false, 100, &inv).is_err(), - "the claim itself still waits for the ChainLock" - ); - } - - /// The InstantSend read-only view carries the decoded islock, and its id - /// matches the claim's InstantSend primary. - #[test] - fn should_inspect_an_instant_invitation_with_the_claims_identity_id() { - let key = voucher_secret(); - let tx = funding_tx(&key); - let txid = tx.txid().to_string(); - let mut islock = InstantLock::default(); - islock.txid = tx.txid(); - let mut islock_bytes = Vec::new(); - islock.consensus_encode(&mut islock_bytes).unwrap(); - let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); - - let funding = inspect_funding_tx(&tx, true, &inv).unwrap(); - assert!(funding.instant_lock.is_some()); - assert_eq!(funding.credit_output_duffs, 100_000); - let primary = assemble_asset_lock_proof(tx, true, 100, &inv) - .unwrap() - .primary; - assert_eq!( - identity_id_for_out_point(funding.out_point), - primary.create_identifier().unwrap() - ); - } - - // --- adopt_already_landed_claim: recognizing our own earlier claim --- - - mod claim_adoption { - use std::cell::Cell; - - use super::super::adopt_already_landed_claim; - use super::{identity_with_keys, our_keys, test_key}; - use crate::PlatformWalletError; - use dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError; - use dpp::consensus::basic::BasicError; - use dpp::consensus::ConsensusError; - use dpp::dashcore::hashes::Hash; - use dpp::dashcore::{OutPoint, Txid}; - use dpp::identity::{Identity, IdentityPublicKey, KeyID}; - use std::collections::BTreeMap; - - fn out_point() -> OutPoint { - OutPoint::new(Txid::from_byte_array([7u8; 32]), 1) - } - - /// Platform's consumed-outpoint rejection for `out_point`. - fn consumed(out_point: OutPoint) -> PlatformWalletError { - PlatformWalletError::Sdk(dash_sdk::Error::Protocol( - dpp::ProtocolError::ConsensusError(Box::new(ConsensusError::BasicError( - BasicError::IdentityAssetLockTransactionOutPointAlreadyConsumedError( - IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( - out_point.txid, - out_point.vout as usize, - ), - ), - ))), - )) - } - - fn is_consumed(result: &Result) -> bool { - matches!( - result, - Err(PlatformWalletError::Sdk(dash_sdk::Error::Protocol(_))) - ) - } - - /// Run the seam with a scripted lookup, counting how often it is called. - async fn adopt( - submitted: Result, - lookup: Result, dash_sdk::Error>, - ) -> (Result, u32) { - let calls = Cell::new(0); - let keys = our_keys(); - let result = adopt_already_landed_claim(submitted, &out_point(), &keys, || { - calls.set(calls.get() + 1); - async { lookup } - }) - .await; - (result, calls.get()) - } - - #[tokio::test] - async fn should_pass_a_successful_claim_through_without_a_lookup() { - let (result, lookups) = adopt(Ok(identity_with_keys(our_keys())), Ok(None)).await; - assert!(result.is_ok()); - assert_eq!(lookups, 0); - } - - #[tokio::test] - async fn should_adopt_our_identity_behind_a_consumed_outpoint() { - let (result, lookups) = adopt( - Err(consumed(out_point())), - Ok(Some(identity_with_keys(our_keys()))), - ) - .await; - assert!(result.is_ok(), "our own landed claim is the claim's result"); - assert_eq!(lookups, 1); - } - - #[tokio::test] - async fn should_report_the_rejection_when_someone_else_claimed_first() { - let theirs: BTreeMap = [(0, test_key(0, 9))].into(); - let (result, _) = adopt( - Err(consumed(out_point())), - Ok(Some(identity_with_keys(theirs))), - ) - .await; - assert!(is_consumed(&result)); - } - - /// A reclaim top-up consumes the outpoint without creating the - /// identity: nothing to adopt. - #[tokio::test] - async fn should_report_the_rejection_when_no_identity_exists() { - let (result, _) = adopt(Err(consumed(out_point())), Ok(None)).await; - assert!(is_consumed(&result)); - } - - /// A failed lookup must not replace the rejection that explains the - /// failure. - #[tokio::test] - async fn should_keep_the_rejection_when_the_lookup_fails() { - let (result, _) = adopt( - Err(consumed(out_point())), - Err(dash_sdk::Error::Generic("timeout".to_string())), - ) - .await; - assert!(is_consumed(&result)); - } - - /// A consumed report for another outpoint is not about this claim. - #[tokio::test] - async fn should_ignore_a_consumed_report_for_another_outpoint() { - let other = OutPoint::new(Txid::from_byte_array([8u8; 32]), 1); - let (result, lookups) = adopt( - Err(consumed(other)), - Ok(Some(identity_with_keys(our_keys()))), - ) - .await; - assert!(is_consumed(&result)); - assert_eq!(lookups, 0); - } - - #[tokio::test] - async fn should_pass_unrelated_errors_through_without_a_lookup() { - let (result, lookups) = adopt( - Err(PlatformWalletError::Sdk(dash_sdk::Error::Generic( - "unrelated".to_string(), - ))), - Ok(Some(identity_with_keys(our_keys()))), - ) - .await; - assert!(matches!( - result, - Err(PlatformWalletError::Sdk(dash_sdk::Error::Generic(_))) - )); - assert_eq!(lookups, 0); - } - } - - /// An islock that locks a DIFFERENT tx than the funding tx is rejected (the - /// txid-binding guard), so a link can't pair a valid islock with a foreign tx. - #[test] - fn assemble_rejects_islock_for_wrong_tx() { - let key = voucher_secret(); - let tx = funding_tx(&key); - let txid = tx.txid().to_string(); - // A default islock carries a zeroed txid, which won't equal the funding tx. - let mut islock_bytes = Vec::new(); - InstantLock::default() - .consensus_encode(&mut islock_bytes) - .unwrap(); - let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); - let err = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap_err(); - assert!(format!("{err}").contains("does not lock the funding transaction")); - } - - // --- fetch_funding_tx_with_retry: the claim propagation-retry seam --- - - mod fetch_retry { - use std::collections::VecDeque; - use std::sync::atomic::{AtomicU32, Ordering}; - use std::sync::{Arc, Mutex}; - - use super::super::{ - fetch_funding_tx_with_retry, reverse_txid_hex, CLAIM_FETCH_MAX_ATTEMPTS, - }; - use crate::PlatformWalletError; - - /// A canonical 64-hex txid whose byte-reversed form differs from it. - fn canonical_txid() -> String { - format!("{}{}", "11".repeat(31), "22") - } - - /// Scripted fetch: pops the next result per call and records the txid - /// each call asked for; plus a counter-only delay. - #[allow(clippy::type_complexity)] - fn harness( - script: Vec, PlatformWalletError>>, - ) -> ( - Arc, PlatformWalletError>>>>, - Arc>>, - Arc, - ) { - ( - Arc::new(Mutex::new(script.into_iter().collect())), - Arc::new(Mutex::new(Vec::new())), - Arc::new(AtomicU32::new(0)), - ) - } - - async fn run( - script: Vec, PlatformWalletError>>, - ) -> (Result, PlatformWalletError>, Vec, u32) { - let canonical = canonical_txid(); - let (queue, calls, delays) = harness(script); - let (queue_f, calls_f, delays_f) = - (Arc::clone(&queue), Arc::clone(&calls), Arc::clone(&delays)); - let result = fetch_funding_tx_with_retry( - &canonical, - move |txid| { - let queue = Arc::clone(&queue_f); - let calls = Arc::clone(&calls_f); - async move { - calls.lock().expect("calls").push(txid); - queue - .lock() - .expect("script") - .pop_front() - .expect("script exhausted — the loop fetched more than scripted") - } - }, - move || { - let delays = Arc::clone(&delays_f); - async move { - delays.fetch_add(1, Ordering::SeqCst); - } - }, - ) - .await; - let recorded = calls.lock().expect("calls").clone(); - (result, recorded, delays.load(Ordering::SeqCst)) - } - - /// A first-call canonical hit returns immediately: one lookup, no - /// reversed fallback, no delay. - #[tokio::test] - async fn canonical_hit_returns_immediately() { - let (result, calls, delays) = run(vec![Ok(Some(7))]).await; - assert_eq!(result.expect("no error"), Some(7)); - assert_eq!(calls, vec![canonical_txid()]); - assert_eq!(delays, 0); - } - - /// A canonical miss falls back to the byte-reversed lookup WITHIN the - /// same attempt (the legacy-iOS little-endian compatibility path) — no - /// delay is spent on the byte-order fallback. - #[tokio::test] - async fn reversed_hit_within_same_attempt_no_delay() { - let (result, calls, delays) = run(vec![Ok(None), Ok(Some(9))]).await; - assert_eq!(result.expect("no error"), Some(9)); - assert_eq!( - calls, - vec![ - canonical_txid(), - reverse_txid_hex(&canonical_txid()).expect("reversible") - ] - ); - assert_eq!(delays, 0); - } - - /// A hit on a later attempt (propagation lag) succeeds after exactly - /// one delay per fully-missed attempt. - #[tokio::test] - async fn hit_after_missed_attempts_counts_one_delay_per_miss() { - // Attempts 1 and 2 miss on both orders; attempt 3 hits canonically. - let (result, calls, delays) = - run(vec![Ok(None), Ok(None), Ok(None), Ok(None), Ok(Some(3))]).await; - assert_eq!(result.expect("no error"), Some(3)); - assert_eq!(calls.len(), 5); - assert_eq!(delays, 2); - } - - /// Exhausting every attempt returns Ok(None) — a miss, not an error — - /// after 2 lookups per attempt, with NO delay after the final attempt. - #[tokio::test] - async fn exhausts_attempts_without_trailing_delay() { - let script = (0..CLAIM_FETCH_MAX_ATTEMPTS * 2) - .map(|_| Ok(None)) - .collect(); - let (result, calls, delays) = run(script).await; - assert_eq!(result.expect("misses are not errors"), None); - assert_eq!(calls.len(), (CLAIM_FETCH_MAX_ATTEMPTS * 2) as usize); - assert_eq!(delays, CLAIM_FETCH_MAX_ATTEMPTS - 1); - } - - /// A transport error is NOT masked as a miss: it propagates - /// immediately, with no further lookups and no extra delay. - #[tokio::test] - async fn transport_error_propagates_immediately() { - // Attempt 1 misses both orders (1 delay); attempt 2's canonical - // lookup errors. - let (result, calls, delays) = run(vec![ - Ok(None), - Ok(None), - Err(PlatformWalletError::InvalidIdentityData( - "simulated transport failure".to_string(), - )), - ]) - .await; - assert!( - matches!(result, Err(PlatformWalletError::InvalidIdentityData(_))), - "transport error must propagate, got {result:?}" - ); - assert_eq!(calls.len(), 3, "no lookup may follow the error"); - assert_eq!(delays, 1); - } - } - - // --- submit_claim_with_stale_islock_fallback: the claim submission seam --- - - mod claim_submission { - use std::collections::VecDeque; - use std::sync::{Arc, Mutex}; - - use super::super::submit_claim_with_stale_islock_fallback; - use super::{funding_tx, voucher_secret}; - use crate::PlatformWalletError; - use dpp::consensus::basic::identity::InvalidInstantAssetLockProofSignatureError; - use dpp::dashcore::InstantLock; - use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; - use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; - use dpp::prelude::AssetLockProof; - - /// The one rejection the seam recovers from: Platform's stale-islock - /// consensus error, in the `Protocol(ConsensusError)` shape - /// `is_instant_lock_proof_invalid` matches. - fn stale_islock_rejection() -> dash_sdk::Error { - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( - InvalidInstantAssetLockProofSignatureError::new().into(), - ))) - } - - /// Any error the seam must NOT treat as a stale islock. - fn unrelated_sdk_error() -> dash_sdk::Error { - dash_sdk::Error::Config("simulated unrelated rejection".to_string()) - } - - /// An InstantSend primary — the variant is what the assertions key on, - /// so a scripted submission log reads as primary-vs-fallback directly. - fn instant_primary() -> AssetLockProof { - AssetLockProof::Instant(InstantAssetLockProof::new( - InstantLock::default(), - funding_tx(&voucher_secret()), - 0, - )) - } - - fn chain_fallback_proof() -> AssetLockProof { - AssetLockProof::Chain(ChainAssetLockProof::new(100, [0u8; 36])) - } - - /// Drive the seam with a scripted submit: each call records the proof - /// it was handed and pops the next scripted result. - async fn run( - chain_fallback: Option, - script: Vec>, - ) -> (Result, Vec) { - let queue: Arc>>> = - Arc::new(Mutex::new(script.into_iter().collect())); - let submitted: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (queue_f, submitted_f) = (Arc::clone(&queue), Arc::clone(&submitted)); - let result = submit_claim_with_stale_islock_fallback( - instant_primary(), - chain_fallback, - move |proof| { - let queue = Arc::clone(&queue_f); - let submitted = Arc::clone(&submitted_f); - async move { - submitted.lock().expect("submitted").push(proof); - queue - .lock() - .expect("script") - .pop_front() - .expect("script exhausted — the seam submitted more than scripted") - } - }, - ) - .await; - let recorded = submitted.lock().expect("submitted").clone(); - (result, recorded) - } - - /// A successful primary submission returns immediately: one submission, - /// and the ChainLock fallback is never touched even though it exists. - #[tokio::test] - async fn primary_success_never_submits_fallback() { - let (result, submitted) = run(Some(chain_fallback_proof()), vec![Ok(7)]).await; - assert_eq!(result.expect("primary success"), 7); - assert_eq!(submitted.len(), 1, "exactly one submission"); - assert!( - matches!(submitted[0], AssetLockProof::Instant(_)), - "the IS primary must be what was submitted" - ); - } - - /// A stale-islock rejection of the primary resubmits the ChainLock - /// fallback over the same outpoint, and the fallback's success is the - /// claim's success. - #[tokio::test] - async fn stale_islock_resubmits_chain_fallback() { - let (result, submitted) = run( - Some(chain_fallback_proof()), - vec![Err(stale_islock_rejection()), Ok(9)], - ) - .await; - assert_eq!(result.expect("fallback success"), 9); - assert_eq!(submitted.len(), 2, "primary then fallback"); - assert!( - matches!(submitted[0], AssetLockProof::Instant(_)), - "the IS primary must be tried first" - ); - assert!( - matches!(submitted[1], AssetLockProof::Chain(_)), - "the resubmission must carry the ChainLock fallback" - ); - } - - /// A stale-islock rejection with NO fallback (funding tx not yet - /// chain-locked) surfaces `AssetLockNotChainLocked` — the clear retry - /// signal — after exactly one submission. - #[tokio::test] - async fn stale_islock_without_fallback_surfaces_retry_signal() { - let (result, submitted) = run(None, vec![Err(stale_islock_rejection())]).await; - assert!( - matches!(result, Err(PlatformWalletError::AssetLockNotChainLocked(_))), - "expected the not-chain-locked retry signal, got {result:?}" - ); - assert_eq!( - submitted.len(), - 1, - "no second submission is possible without a fallback" - ); - } - - /// An unrelated SDK error propagates unchanged after one submission: - /// the fallback exists but must NOT be tried for it. - #[tokio::test] - async fn unrelated_error_propagates_without_second_submission() { - let (result, submitted) = run( - Some(chain_fallback_proof()), - vec![Err(unrelated_sdk_error())], - ) - .await; - assert!( - matches!(result, Err(PlatformWalletError::Sdk(_))), - "the unrelated error must propagate as Sdk, got {result:?}" - ); - assert_eq!( - submitted.len(), - 1, - "an unrelated error must not trigger the fallback" - ); - } - - /// The recovery is attempted exactly once: a fallback rejection — even - /// one wearing the stale-islock shape again — propagates rather than - /// looping. - #[tokio::test] - async fn fallback_rejection_is_not_recovered_again() { - let (result, submitted) = run( - Some(chain_fallback_proof()), - vec![Err(stale_islock_rejection()), Err(stale_islock_rejection())], - ) - .await; - assert!( - matches!(result, Err(PlatformWalletError::Sdk(_))), - "the fallback's own rejection must propagate as Sdk, got {result:?}" - ); - assert_eq!(submitted.len(), 2, "no third submission may follow"); - } - } - // --- create_invitation: the durable-persistence precondition --- mod durability_gate { diff --git a/packages/rs-sdk/src/platform/dashpay/invitation.rs b/packages/rs-sdk/src/platform/dashpay/invitation.rs new file mode 100644 index 00000000000..70c490a0a33 --- /dev/null +++ b/packages/rs-sdk/src/platform/dashpay/invitation.rs @@ -0,0 +1,2422 @@ +//! DashPay invitation link codec and claim — DIP-13 sub-feature 3', legacy-compatible. +//! +//! An invitation packages a one-time ECDSA **voucher** private key together with +//! a reference to the InstantSend-locked funding transaction, so an invitee with +//! no Dash can register their own identity from it. The inviter optionally +//! includes their own DPNS username so the invitee can send a contact request +//! back. +//! +//! The link is the **legacy query form** shared with the reference wallets +//! (`dash-wallet` Android, `dashwallet-ios`), so a link produced here is +//! field-level cross-claimable with those wallets and vice versa: +//! +//! ```text +//! dashpay://invite +//! ?du= +//! &assetlocktx= +//! &pk= +//! &islock= # or omitted / "null" +//! [&display-name=] +//! [&avatar-url=] +//! ``` +//! +//! The interop contract is **emit strict/canonical, parse lenient** — exactly as +//! tolerantly as the live wallets: +//! - Parse accepts both the `dashpay://invite` scheme and the +//! `https://invitations.dashpay.io/applink` host, by field name and +//! order-independent (the two legacy wallets differ in param order). +//! - `islock` is optional: a missing param **and** the literal string `"null"` +//! (which Android emits for chainlock-confirmed invites) both mean "no instant +//! lock" — the claim reconstructs a ChainLock proof instead. The hex decodes +//! as the deterministic InstantSend lock; a lock in the older +//! non-deterministic format does not decode, so the claim fails rather than +//! guessing (no live wallet emits that format). +//! - `assetlocktx` is kept as the raw hex string; the claim tries it as-given +//! then byte-reversed on a fetch miss, mirroring the legacy endianness retry. +//! +//! # Security +//! +//! The `voucher_key` is **bearer money** — whoever holds the link can claim the +//! funded identity. The URI is a secret: callers MUST NOT log or persist it, and +//! the voucher key is never stored (it is HD-derived and re-derivable from the +//! funding index). Parsing is bounded (URI length cap) so a hostile link can't +//! force a large allocation; the WIF is decoded + compression-checked at parse, +//! and its network is validated against the wallet at claim (a wrong-network WIF +//! is a valid key on the wrong chain, caught before the funding fetch). +//! +//! What the design does and does not protect against: +//! - Every theft reduces to "who holds the link". Platform checks the claim's +//! signature against the key of the asset-lock output, and the new identity's +//! id is derived from the funding outpoint, so someone who watches a claim in +//! flight but lacks the voucher key cannot redirect it, and two racing claims +//! target the same identity (exactly one commits). +//! - The inviter can always re-derive the voucher key, so after handing over a +//! link it can still claim or reclaim the voucher first. Nothing is stolen +//! (the funds were the inviter's), but the invitee is denied onboarding with +//! no sign of why. For the same reason, reclaiming a leaked link is a race the +//! inviter can lose. +//! - Nothing signs the link, so whoever controls the channel it travels over +//! can swap in a different invite. The worst outcome is the invitee sending a +//! contact request to the attacker's identity, which an ordinary contact +//! request achieves anyway. Signing would not help: the channel is the trust +//! root. +//! - Because the identity id comes from the funding outpoint, the inviter knows +//! the invitee's identity id before the invitee claims it. +//! - The legacy wallets wrapped the link in an AppsFlyer OneLink, which sends +//! the plaintext `pk` to AppsFlyer's servers. We parse that host but never +//! emit it. + +use std::collections::BTreeMap; + +use dpp::dashcore::consensus::Decodable; +use dpp::dashcore::secp256k1::{PublicKey, SecretKey}; +use dpp::dashcore::transaction::special_transaction::TransactionPayload; +use dpp::dashcore::{InstantLock, Network, OutPoint, PrivateKey, ScriptBuf, Transaction, TxOut}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; +use dpp::identity::v0::IdentityV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose, SecurityLevel}; +use dpp::prelude::{AssetLockProof, Identifier}; + +use crate::core::FetchedCoreTransaction; +use crate::platform::transition::asset_lock_submission::{ + is_asset_lock_already_consumed, is_instant_lock_proof_invalid, submit_with_cl_height_retry, +}; +use crate::platform::transition::put_identity::PutIdentity; +use crate::platform::transition::put_settings::PutSettings; +use crate::platform::Fetch; +use crate::{Error, Sdk}; + +/// Hard cap on the amount an invitation can lock (0.26 DASH). The voucher is a +/// bearer credential, so the blast radius of a leaked link is bounded here in +/// Rust — not just in the UI. Sized for onboarding at BOTH username tiers: the +/// invitee spends the voucher on identity creation **plus** a DPNS name — a +/// normal name (~0.03 DASH, the legacy `DASH_PAY_FEE`) or a contested/premium +/// name (~0.25 DASH, `DASH_PAY_FEE_CONTESTED`), with the 0.01 margin covering +/// the create/claim fees. The earlier 0.05 value deferred the contested tier +/// "until contested-name-via-invite claim exists" — the claim path is +/// amount-agnostic and contested-invite claims are verified working, and the +/// Android wallet has funded contested invitations at 0.25 since 2024, so the +/// deferral is over. (The pre-merge 0.01 iteration was below even a usable +/// non-contested invitation and rejected its own onboarding default.) +pub const MAX_INVITATION_DUFFS: u64 = 26_000_000; + +/// Floor on the amount an invitation can lock (0.003 DASH). A voucher funds a +/// Platform identity operation, and creating an identity — which is what the +/// invitee's claim does, and what a register-target reclaim does — requires the +/// asset lock to carry at least the identity-registration minimum (~0.00228 DASH +/// in credits on the current network; the state transition is rejected with +/// `IdentityAssetLockTransactionOutPointNotEnoughBalanceError` below it). A +/// voucher under this floor produces an invitation that can be neither claimed +/// nor reclaimed, so reject it at creation. Set above the bare floor to leave the +/// new identity a small usable starting balance; tune if the floor changes. +pub const MIN_INVITATION_DUFFS: u64 = 300_000; + +/// Default TTL (24h) for an invitation's advisory expiry. The creating wallet +/// sets `expiry_unix = now + MAX_INVITATION_TTL_SECS`. The expiry is **advisory** — a +/// leaked-link finder holds the voucher key and ignores it — so it bounds only +/// the honest UI (don't submit an about-to-go-stale IS proof) and the reclaim +/// signal, NOT a leaked-link window. The real leak bound is `MAX_INVITATION_DUFFS`. +pub const MAX_INVITATION_TTL_SECS: u32 = 24 * 60 * 60; + +/// Claim-by-fetch bound: how many times to look up the funding tx (each attempt +/// tries both byte orders) before giving up. InstantSend/ChainLock finality does +/// not guarantee the invitee's DAPI node has indexed the tx yet, so a freshly +/// shared invitation can miss purely from propagation lag; retrying bounds that. +const CLAIM_FETCH_MAX_ATTEMPTS: u32 = 5; + +/// Fixed backoff between claim-by-fetch attempts. 5 attempts × 3s ≈ 12s of +/// tolerance for propagation delay before surfacing a "not found" error. +const CLAIM_FETCH_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(3); + +/// Why an invitation could not be encoded, parsed, inspected or claimed. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, thiserror::Error)] +pub enum InvitationError { + /// The link, or the funding transaction it names, is not a usable + /// invitation. + #[error("invalid invitation: {0}")] + Invalid(String), + + /// The voucher key is for the other network (a testnet link opened in a + /// mainnet wallet, or the reverse). Definitive: the claim refuses the link + /// before any network work. + #[error("invitation is for the {invitation:?} network but this wallet is on {wallet:?}")] + NetworkMismatch { + invitation: Network, + wallet: Network, + }, + + /// Platform rejected the link's islock as stale and the funding + /// transaction is not chain-locked yet, so no ChainLock proof can replace + /// it. Retry once the funding block is chain-locked. + #[error("invitation funding transaction is not chain-locked: {0}")] + NotChainLocked(String), + + /// An SDK call failed. + #[error(transparent)] + Sdk(#[from] Error), +} + +/// The `dashpay://invite` custom scheme — the canonical form we emit and the +/// primary form we parse (QR / in-person / deep link). `invite` is the URI +/// authority (Android's `URI_PREFIX`), so the accepted host is exactly `invite`. +const INVITATION_SCHEME: &str = "dashpay"; +const INVITATION_SCHEME_HOST: &str = "invite"; +/// The full canonical prefix we emit. +const INVITATION_SCHEME_PREFIX: &str = "dashpay://invite"; + +/// The AppsFlyer OneLink applink the iOS reference wallet emits +/// (`https://invitations.dashpay.io/applink?…`). Parsed as a first-class +/// alternative to the custom scheme (field-level interop). +const INVITATION_APPLINK_HOST: &str = "invitations.dashpay.io"; +const INVITATION_APPLINK_PATH: &str = "/applink"; + +/// Query parameter names — identical to the legacy wallets' contract. +const PARAM_USER: &str = "du"; +const PARAM_ASSET_LOCK_TX: &str = "assetlocktx"; +const PARAM_PRIVATE_KEY: &str = "pk"; +const PARAM_IS_LOCK: &str = "islock"; +const PARAM_DISPLAY_NAME: &str = "display-name"; +const PARAM_AVATAR_URL: &str = "avatar-url"; + +/// Android emits this literal for the `islock` value when the funding was +/// confirmed by a ChainLock rather than an InstantSend lock; treat it as "no +/// instant lock" (reconstruct a ChainLock proof at claim), NOT as hex to decode. +const IS_LOCK_NULL_SENTINEL: &str = "null"; + +/// Max chars of the whole URI accepted before parsing (anti-DoS). A real link — +/// username + txid (64) + WIF (~52) + islock hex (~400) + optional avatar url — +/// is well under 2 KB; 8192 is comfortable headroom while bounding the +/// allocation a hostile link can force. +const MAX_INVITATION_URI_LEN: usize = 8192; + +/// Max length (bytes) of a UTF-8 string field (username / display name / avatar +/// url). DPNS labels are short; this only bounds a hostile link. +const MAX_STR_BYTES: usize = 2048; + +/// Inviter contact-bootstrap info — present iff the link carries any of `du`, +/// `display-name`, or `avatar-url`. Absent ⇒ pure funding voucher. The link does +/// not carry the inviter's identity id (the legacy format has no such field); +/// the invitee resolves it from `username` via DPNS at contact-bootstrap time. +/// +/// `username` is optional: the reference wallets emit `display-name`/`avatar-url` +/// in blocks independent of `du`, so a `du`-less link can still carry inviter +/// metadata (surfaced in the preview even though no contact request is possible +/// without a username). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InviterInfo { + /// The inviter's DPNS username (`du`), shown to the invitee and used to + /// resolve the inviter's identity for the contact request. `None` for a + /// metadata-only (`du`-less) link. + pub username: Option, + /// Optional display name (`display-name`) for the claim UI. + pub display_name: Option, + /// Optional avatar url (`avatar-url`) for the claim UI. + pub avatar_url: Option, +} + +/// A decoded invitation, ready for claim. +/// +/// Unlike the funding proof (which is fetched at claim), everything here comes +/// straight from the link: the bearer voucher key, the funding txid to fetch, +/// and the optional InstantSend lock hex. +pub struct ParsedInvitation { + /// One-time ECDSA voucher private key that funds the invitee's identity + /// create (signs the asset-lock's outer state-transition signature). + pub voucher_key: SecretKey, + /// The network the voucher key's WIF was encoded for (`0xCC` ⇒ Mainnet, + /// `0xEF` ⇒ the testnet family). The claim path rejects a link whose WIF + /// network does not match the wallet's, turning a wrong-network link into a + /// clear error instead of a mysterious fetch miss. + pub voucher_key_network: Network, + /// The funding transaction id as carried in the link (`assetlocktx`), + /// lowercased. Kept as the raw hex string so the claim can try it as-given + /// and byte-reversed on a fetch miss (old iOS links are little-endian). + pub funding_txid: String, + /// The InstantSend lock, lowercase consensus hex — `None` when the link + /// omitted `islock` or set it to `"null"` (a ChainLock-confirmed invite). + pub islock_hex: Option, + /// Inviter contact-bootstrap info; `None` ⇒ pure funding voucher (no `du`). + pub inviter: Option, +} + +impl Drop for ParsedInvitation { + /// Scrub the voucher scalar on drop — it is literal bearer money. Mirrors + /// the resolver signer's key hygiene (`WipingSecretKey`). + fn drop(&mut self) { + self.voucher_key.non_secure_erase(); + } +} + +impl std::fmt::Debug for ParsedInvitation { + /// Redacts the voucher key and the funding txid — the whole point of the + /// type is to carry a bearer secret, which must never reach a log. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ParsedInvitation") + .field("voucher_key", &"") + .field("funding_txid", &"") + .field("has_islock", &self.islock_hex.is_some()) + .field("inviter", &self.inviter) + .finish_non_exhaustive() + } +} + +fn invalid(msg: impl Into) -> InvitationError { + InvitationError::Invalid(msg.into()) +} + +/// The P2PKH script the voucher key controls (compressed-pubkey hash160). This +/// is the selector that binds the voucher key to its funded credit output. +fn voucher_credit_script(voucher_key: &SecretKey) -> ScriptBuf { + let pubkey = PublicKey::from_secret_key(voucher_key); + let hash = dpp::dashcore::PublicKey::new(pubkey).pubkey_hash(); + ScriptBuf::new_p2pkh(&hash) +} + +// --------------------------------------------------------------------------- +// Percent-encoding (URI query values). Only %XX escapes — never `+` for space +// (that is form encoding; `Uri.getQueryParameter` on the legacy wallets does +// not treat `+` as space, so neither do we). +// --------------------------------------------------------------------------- + +/// Percent-encode a query value, passing the RFC 3986 unreserved set through +/// untouched (which covers hex, base58 WIF, and DPNS labels) and `%`-escaping +/// everything else. Encoding an already-safe value is a no-op. +fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &b in s.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +/// Decode a percent-encoded query value (`%XX` → byte). Leaves `+` literal. +/// Errors on a malformed escape or non-UTF-8 result. +fn percent_decode(s: &str) -> Result { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hi = bytes + .get(i + 1) + .and_then(|c| (*c as char).to_digit(16)) + .ok_or_else(|| invalid("malformed percent-escape in invitation link"))?; + let lo = bytes + .get(i + 2) + .and_then(|c| (*c as char).to_digit(16)) + .ok_or_else(|| invalid("malformed percent-escape in invitation link"))?; + out.push((hi * 16 + lo) as u8); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).map_err(|_| invalid("invitation link field is not valid UTF-8")) +} + +/// Split a query string (`k1=v1&k2=v2`) into percent-decoded key/value pairs, +/// order-independent. A blank segment or a segment without `=` is skipped. +fn parse_query(query: &str) -> Result, InvitationError> { + let mut pairs = Vec::new(); + for segment in query.split('&') { + if segment.is_empty() { + continue; + } + let Some((raw_key, raw_val)) = segment.split_once('=') else { + continue; + }; + pairs.push((percent_decode(raw_key)?, percent_decode(raw_val)?)); + } + Ok(pairs) +} + +/// Look up a field by name (first match wins), returning it trimmed. `None` for +/// a missing or blank value. +fn field<'a>(pairs: &'a [(String, String)], name: &str) -> Option<&'a str> { + pairs + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.trim()) + .filter(|v| !v.is_empty()) +} + +/// The parts of an invitation URI we gate on. Extracted with a small hand parser +/// (no `url` crate in this crate's deps) so the transport check is anchored to +/// the real scheme/host/path — not a substring/prefix that a decoy URL could +/// smuggle (`dashpay://inviteXYZ`, `https://evil/?next=invitations.dashpay.io/applink&…`). +struct UriParts<'a> { + scheme: &'a str, + /// Authority host, lowercased for comparison (userinfo + port stripped). + host: String, + path: &'a str, + query: Option<&'a str>, +} + +/// Strip `userinfo@` and `:port` from an authority, leaving the bare host. +fn authority_host(authority: &str) -> &str { + let after_userinfo = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + // A bare `host:port` — take the host. (Our hosts are never bracketed IPv6.) + after_userinfo.split(':').next().unwrap_or(after_userinfo) +} + +/// Split `scheme://authority/path?query#fragment` into the parts we gate on. +/// Requires the `://` authority form (both accepted transports use it) and drops +/// the fragment. Returns `None` for a shape we don't recognize as a URI. +fn split_uri(uri: &str) -> Option> { + let (scheme, rest) = uri.split_once(':')?; + let rest = rest.strip_prefix("//")?; + let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..auth_end]; + let after_auth = &rest[auth_end..]; + // Drop the fragment, then split path from query. + let after_auth = after_auth.split('#').next().unwrap_or(after_auth); + let (path, query) = match after_auth.split_once('?') { + Some((p, q)) => (p, Some(q)), + None => (after_auth, None), + }; + Some(UriParts { + scheme, + host: authority_host(authority).to_ascii_lowercase(), + path, + query, + }) +} + +/// Encode an invitation into a legacy-compatible `dashpay://invite?…` link. +/// +/// The returned URI **contains the plaintext voucher key** (WIF) — treat it as a +/// secret (do not log or persist it). `network` sets the WIF network byte +/// (`0xCC` mainnet / `0xEF` testnet) and the key is emitted **compressed** (the +/// credit-output hash160 uses the compressed pubkey). The funding txid and +/// InstantSend lock are read from `asset_lock`; a ChainLock proof emits no +/// `islock`. `inviter` is `Some` only when the inviter opted into the +/// contact-bootstrap (the link then carries `du`/`display-name`/`avatar-url`). +pub fn encode_invitation_uri( + voucher_key: &SecretKey, + network: Network, + asset_lock: &AssetLockProof, + inviter: Option<&InviterInfo>, +) -> Result { + // txid (big-endian display hex) + optional islock hex from the proof. + let (funding_txid, islock_hex) = match asset_lock { + AssetLockProof::Instant(instant) => { + let txid = instant.transaction().txid().to_string(); + let islock = hex::encode(dpp::dashcore::consensus::serialize(instant.instant_lock())); + (txid, Some(islock)) + } + AssetLockProof::Chain(chain) => (chain.out_point.txid.to_string(), None), + }; + + // WIF, compressed (the default for `PrivateKey::new`), network-correct. + let wif = PrivateKey::new(*voucher_key, network).to_wif(); + + let mut query = String::new(); + let mut push = |key: &str, val: &str| { + if !query.is_empty() { + query.push('&'); + } + query.push_str(key); + query.push('='); + query.push_str(&percent_encode(val)); + }; + + // `du` is emitted first (canonical) when the inviter opted in with a + // username; a pure funding voucher emits a `du`-less link (still parseable — + // iOS accepts it). + if let Some(username) = inviter.and_then(|info| info.username.as_ref()) { + if username.len() > MAX_STR_BYTES { + return Err(invalid("inviter username too long")); + } + push(PARAM_USER, username); + } + push(PARAM_ASSET_LOCK_TX, &funding_txid); + push(PARAM_PRIVATE_KEY, &wif); + if let Some(islock) = &islock_hex { + push(PARAM_IS_LOCK, islock); + } + if let Some(info) = inviter { + if let Some(display_name) = &info.display_name { + if display_name.len() > MAX_STR_BYTES { + return Err(invalid("inviter display name too long")); + } + push(PARAM_DISPLAY_NAME, display_name); + } + if let Some(avatar_url) = &info.avatar_url { + if avatar_url.len() > MAX_STR_BYTES { + return Err(invalid("inviter avatar url too long")); + } + push(PARAM_AVATAR_URL, avatar_url); + } + } + + let uri = format!("{INVITATION_SCHEME_PREFIX}?{query}"); + // Reject a link the parser itself would reject. `MAX_STR_BYTES` bounds each + // field's RAW bytes, but percent-encoding can expand a field up to 3×, so + // several near-cap fields can push the formatted URI past + // `MAX_INVITATION_URI_LEN` (the exact cap `parse_invitation_uri` enforces). + // Fail here rather than emit a self-unparseable link — the create path + // surfaces this as a hard error *after* the funded voucher is recorded, so it + // stays reclaimable. + if uri.len() > MAX_INVITATION_URI_LEN { + return Err(invalid(format!( + "invitation link too long ({} chars; max {MAX_INVITATION_URI_LEN})", + uri.len() + ))); + } + Ok(uri) +} + +/// Parse a legacy-compatible invitation link into a [`ParsedInvitation`]. +/// +/// Accepts exactly the `dashpay://invite` scheme (host `invite`) and the +/// `https://invitations.dashpay.io/applink` URL (host + path anchored, so a +/// decoy URL that merely mentions the applink cannot smuggle a query in). Fields +/// are matched by name, order-independent. Requires `assetlocktx` + `pk` +/// (non-blank, each appearing at most once); `du` and `islock` are optional (a +/// missing/`"null"` `islock` is a ChainLock invite). The WIF is decoded and +/// compression-checked here; its **network** is captured for the claim path to +/// validate against the wallet (a wrong-network WIF is a valid key on the wrong +/// chain, so it must be caught there, not here). Does **not** fetch or validate +/// the funding tx — that happens at claim. +pub fn parse_invitation_uri(uri: &str) -> Result { + if uri.len() > MAX_INVITATION_URI_LEN { + return Err(invalid(format!( + "invitation link too long ({} chars; max {MAX_INVITATION_URI_LEN})", + uri.len() + ))); + } + + // Anchor the transport gate to the real scheme/host/path, not a prefix or + // substring: `dashpay://invite` (host == `invite`) or the applink URL + // (host == invitations.dashpay.io, path == /applink). A trailing slash on + // the applink path is tolerated. + let parts = split_uri(uri).ok_or_else(|| invalid("not a valid invitation link URI"))?; + let accepted = match parts.scheme.to_ascii_lowercase().as_str() { + INVITATION_SCHEME => { + parts.host == INVITATION_SCHEME_HOST && (parts.path.is_empty() || parts.path == "/") + } + "https" | "http" => { + parts.host == INVITATION_APPLINK_HOST + && parts.path.trim_end_matches('/') == INVITATION_APPLINK_PATH + } + _ => false, + }; + if !accepted { + return Err(invalid( + "not a dashpay://invite or https://invitations.dashpay.io/applink link", + )); + } + + let query = parts + .query + .ok_or_else(|| invalid("invitation link has no query parameters"))?; + let pairs = parse_query(query)?; + + // A duplicated required key is ambiguous and dangerous: the two reference + // wallets bind different occurrences (iOS the last, Android the first), so a + // `?pk=A&pk=B` link would claim different keys per wallet. Reject it. + for key in [PARAM_PRIVATE_KEY, PARAM_ASSET_LOCK_TX] { + if pairs.iter().filter(|(k, _)| k == key).count() > 1 { + return Err(invalid(format!( + "invitation link has a duplicated `{key}` field" + ))); + } + } + + // iOS minimum: `assetlocktx` + `pk` present and non-blank. Never reject on a + // missing/`"null"` `islock` or a missing `du`. + let assetlocktx = field(&pairs, PARAM_ASSET_LOCK_TX) + .ok_or_else(|| invalid("invitation link is missing the assetlocktx field"))?; + let pk = field(&pairs, PARAM_PRIVATE_KEY) + .ok_or_else(|| invalid("invitation link is missing the pk field"))?; + + // WIF: decode + require compression (the credit-output hash uses the + // compressed pubkey — an uncompressed key would produce a mismatching + // hash160 and a dead claim). `from_wif` rejects a non-Dash network byte but + // cannot tell mainnet from testnet against *this* wallet; the claim path does + // that (see `voucher_key_network`). + // `PrivateKey`/`SecretKey` are `Copy`, so the decoded binding is a second + // bearer-scalar copy alongside `voucher_key` — erase it on every exit + // (rejection included; `ParsedInvitation`'s wipe-on-drop guard doesn't + // exist yet on that path) to keep the parser's wiping policy airtight. + let mut private_key = PrivateKey::from_wif(pk) + .map_err(|e| invalid(format!("invitation pk is not a valid WIF key: {e}")))?; + if !private_key.compressed { + private_key.inner.non_secure_erase(); + return Err(invalid("invitation pk must be a compressed WIF key")); + } + let voucher_key = private_key.inner; + let voucher_key_network = private_key.network; + private_key.inner.non_secure_erase(); + + let funding_txid = assetlocktx.to_lowercase(); + + // `islock`: a missing param and the literal `"null"` both mean no instant + // lock. Kept as lowercase hex; decoded to an `InstantLock` only at claim. + let islock_hex = field(&pairs, PARAM_IS_LOCK) + .filter(|v| *v != IS_LOCK_NULL_SENTINEL) + .map(|v| v.to_lowercase()); + + // Inviter metadata is parsed field-by-field, independent of `du` (the + // reference wallets emit `display-name`/`avatar-url` in blocks separate from + // `du`, so a `du`-less link can still carry them). Present iff any field is. + // The identity id is not in the link; it is resolved from the username at + // contact-bootstrap time. + // + // Note: an unencoded `&` inside a legacy `avatar-url` would truncate that + // field at the naive `&` split. Cosmetic (avatar only, low likelihood) — not + // worth a full nested-query parser; the required fields are unaffected. + let username = field(&pairs, PARAM_USER).map(str::to_string); + let display_name = field(&pairs, PARAM_DISPLAY_NAME).map(str::to_string); + let avatar_url = field(&pairs, PARAM_AVATAR_URL).map(str::to_string); + let inviter = if username.is_some() || display_name.is_some() || avatar_url.is_some() { + Some(InviterInfo { + username, + display_name, + avatar_url, + }) + } else { + None + }; + + Ok(ParsedInvitation { + voucher_key, + voucher_key_network, + funding_txid, + islock_hex, + inviter, + }) +} + +/// Whether a voucher WIF encoded for `pk_network` is usable on a wallet running +/// `wallet_network`. +/// +/// WIF carries only two network bytes — `0xCC` (Mainnet) and `0xEF` (the whole +/// testnet family: Testnet/Devnet/Regtest) — so the meaningful check is +/// mainnet-vs-not: a testnet/devnet/regtest wallet accepts any `0xEF` WIF, and a +/// mainnet wallet accepts only a `0xCC` WIF. This catches a testnet link pasted +/// into a mainnet wallet (a valid key on the wrong chain) as a clear error +/// rather than a mysterious funding-tx fetch miss. +pub fn wif_network_matches(pk_network: Network, wallet_network: Network) -> bool { + matches!(pk_network, Network::Mainnet) == matches!(wallet_network, Network::Mainnet) +} + +/// Select the funded credit output the voucher key controls. +/// +/// The link carries the voucher key but not the output index; scan the fetched +/// asset-lock transaction's credit outputs for the one whose `script_pubkey` +/// matches the voucher key's P2PKH (compressed pubkey), and return its index. +/// Rejects a transaction with no matching credit output. The pk↔output binding +/// is itself consensus-enforced, so this is a fail-fast + correct-index guard, +/// not a security boundary — but selecting (rather than hard-coding index 0) is +/// required: a legacy invite's credit output need not be at index 0. +pub fn voucher_output_index( + transaction: &Transaction, + voucher_key: &SecretKey, +) -> Result { + voucher_credit_output(transaction, voucher_key).map(|(index, _)| index) +} + +/// [`voucher_output_index`] plus the selected credit output itself, so a +/// caller that needs the voucher's value reads it from the same match. +pub fn voucher_credit_output<'a>( + transaction: &'a Transaction, + voucher_key: &SecretKey, +) -> Result<(u32, &'a TxOut), InvitationError> { + let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &transaction.special_transaction_payload + else { + return Err(invalid( + "funding transaction is not an asset-lock special transaction", + )); + }; + let expected = voucher_credit_script(voucher_key); + payload + .credit_outputs + .iter() + .enumerate() + .find(|(_, out)| out.script_pubkey == expected) + .map(|(index, out)| (index as u32, out)) + .ok_or_else(|| { + invalid("voucher key does not control any credit output of the funding transaction") + }) +} + +/// The invitee's pre-claim view of an invitation, from +/// [`Sdk::invitation_claim_status`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvitationClaimStatus { + /// The identity the claim would create (derived from the credit outpoint). + pub prospective_identity_id: Identifier, + /// Value of the credit output the voucher key controls (duffs). + pub amount_duffs: u64, + /// The claim would submit an InstantSend proof (the link carried an islock). + pub is_instant: bool, + /// The funding transaction is chain-locked. `false` with `is_instant == + /// false` is a ChainLock-only invitation whose claim must wait for it. + pub is_chain_locked: bool, + /// An identity exists at `prospective_identity_id` — definitively claimed. + /// `false` does NOT prove the voucher is unspent (see + /// [`Sdk::invitation_prospective_identity_id`]). + pub already_claimed: bool, +} + +/// Pre-flight the caller-supplied identity keys map: id=0 must be a MASTER-level +/// AUTHENTICATION key (it signs the IdentityCreate transition). +fn preflight_keys_map(keys_map: &BTreeMap) -> Result<(), InvitationError> { + if keys_map.is_empty() { + return Err(InvitationError::Invalid( + "keys_map must contain at least one identity public key".to_string(), + )); + } + match keys_map.get(&0) { + Some(k) + if k.security_level() == SecurityLevel::MASTER + && k.purpose() == Purpose::AUTHENTICATION => {} + Some(_) => { + return Err(InvitationError::Invalid( + "keys_map[0] must be a MASTER-level AUTHENTICATION key \ + (required to sign the IdentityCreate transition)" + .to_string(), + )) + } + None => { + return Err(InvitationError::Invalid( + "keys_map must include key id=0 with MASTER security level".to_string(), + )) + } + } + Ok(()) +} + +/// Byte-reverse a txid hex string. Old iOS invitation links carry the funding +/// txid in little-endian internal order; DAPI keys `getTransaction` by the +/// big-endian display id, so the claim retries with the id reversed on a miss +/// (mirrors Android's `Sha256Hash.wrap(id).reversedBytes` retry). +fn reverse_txid_hex(txid_hex: &str) -> Result { + let mut bytes = hex::decode(txid_hex).map_err(|e| { + InvitationError::Invalid(format!("invitation funding txid is not valid hex: {e}")) + })?; + bytes.reverse(); + Ok(hex::encode(bytes)) +} + +/// RAII scrub for the voucher `PrivateKey` copy used on the claim path. +/// `dashcore::PrivateKey` wraps a `secp256k1::SecretKey` that has no +/// Drop-zeroize, so the imported bearer scalar would otherwise linger in memory +/// after `claim_invitation` returns on every exit path (success or error). +struct WipingPrivateKey(PrivateKey); + +impl Drop for WipingPrivateKey { + fn drop(&mut self) { + self.0.inner.non_secure_erase(); + } +} + +impl Sdk { + /// The identity id this invitation WOULD create, without claiming it. + /// + /// Platform derives a created identity's id from the asset-lock outpoint, + /// so the id is knowable before the claim. The claim is otherwise the only + /// way to learn a voucher is spent, which is why a used invitation surfaces + /// as a raw "asset lock … already completely used" after the invitee has + /// picked a username and entered their PIN. + /// + /// # Detects claims, not consumption — the signal is ONE-WAY + /// + /// An identity existing under the returned id means the voucher was + /// **definitely** claimed. Its absence does **not** mean the voucher is + /// usable. + /// + /// The lock can also be consumed by `IdentityTopUp` — an inviter reclaiming + /// the voucher into one of its existing identities — which credits an + /// EXISTING identity rather than creating the derived one. Afterwards no + /// identity exists at this id, yet a claim still fails deterministically + /// because the asset-lock output is already spent. + /// + /// Platform exposes no client query for spent asset locks (drive tracks + /// them under `SpentAssetLockTransactions`, but no DAPI endpoint surfaces + /// it), so consumption cannot be checked from here. Callers must therefore + /// treat this as a fast-fail for the common case only: reject the + /// invitation when an identity exists, and otherwise proceed WITHOUT + /// concluding the voucher is usable. It narrows when the late failure + /// happens; it does not remove it. + /// + /// Costs one funding-tx fetch: the outpoint is not in the link (the credit + /// output is selected by pk↔script match, not by index), so the tx has to + /// be refetched exactly as the claim does. Same wrong-network fail-fast as + /// [`Sdk::claim_invitation`], so a testnet link on mainnet reports the + /// network mismatch rather than a confusing fetch miss. + pub async fn invitation_prospective_identity_id( + &self, + invitation: &ParsedInvitation, + ) -> Result { + let funding = inspect_invitation_funding(self, invitation).await?; + Ok(identity_id_for_out_point(funding.out_point)) + } + + /// What an invitation is worth and whether it was already claimed — the + /// invitee's pre-claim check, one network round trip before any username + /// is picked. + /// + /// The amount is the tier signal: the link does not say whether it funds a + /// contested or a non-contested username, the value of the voucher's credit + /// output does (the inviter chose it). It is read from the same refetched + /// funding transaction the claim uses, so it is exactly what the claim + /// would spend. + /// + /// `already_claimed` carries the one-way caveat of + /// [`Sdk::invitation_prospective_identity_id`]: `true` is definitive, + /// `false` does not prove the voucher is unspent (a reclaim top-up consumes + /// it without creating the derived identity). + /// + /// It describes the funding transaction rather than requiring a buildable + /// proof: a ChainLock-only link whose funding tx is not chain-locked yet + /// reports `is_instant == false, is_chain_locked == false` and its amount + /// instead of failing (the claim of such a link waits for the ChainLock). + /// A failed identity lookup still fails the whole call. + /// + /// Costs one funding-tx fetch (with the claim's bounded propagation retry) + /// and one identity fetch. Same wrong-network fail-fast as the claim. + pub async fn invitation_claim_status( + &self, + invitation: &ParsedInvitation, + ) -> Result { + let funding = inspect_invitation_funding(self, invitation).await?; + let prospective_identity_id = identity_id_for_out_point(funding.out_point); + let already_claimed = Identity::fetch(self, prospective_identity_id) + .await? + .is_some(); + Ok(InvitationClaimStatus { + prospective_identity_id, + amount_duffs: funding.credit_output_duffs, + is_instant: funding.instant_lock.is_some(), + is_chain_locked: funding.is_chain_locked, + already_claimed, + }) + } + + /// Claim a DashPay invitation: register a NEW identity for the invitee, + /// funded by the imported voucher. + /// + /// The link carries only the voucher key + funding txid (+ optional islock), + /// not the funding proof — so this **refetches** the funding transaction + /// from Core and reconstructs the asset-lock proof, mirroring the legacy + /// Android claim (`TopUpRepository.obtainAssetLockTransaction`): + /// 1. Fetch the tx by `funding_txid`; retry byte-reversed on a miss (old iOS + /// links are little-endian). + /// 2. Fail-fast that the fetched tx is really the funding tx, and (if an + /// islock is present) that the islock locks it. + /// 3. Select the funded credit output by pk↔script match (not index 0). + /// 4. Build an `InstantAssetLockProof` when an islock is present (the fast + /// path), else a `ChainAssetLockProof` once the funding tx is chain-locked. + /// + /// The IS proof is the *fast* path only: a voucher islock is signed at + /// creation, so by the time the invitee claims (minutes-to-hours later, and on + /// testnet possibly across a quorum rotation) Drive may reject the islock as + /// stale with `InvalidInstantAssetLockProofSignatureError` — its own message + /// asks to "try chain asset lock proof instead". When the funding tx is + /// already chain-locked (the usual case by claim time), this resubmits a + /// `ChainAssetLockProof` over the SAME credit output. If the tx is not yet + /// chain-locked no fallback is possible and the claim returns + /// [`InvitationError::NotChainLocked`] as a retry signal. + /// + /// The invitee's own identity keys (`keys_map`) are signed by + /// `identity_signer`; the asset-lock's outer state-transition signature is + /// produced from the **imported voucher key** (`invitation.voucher_key`) + /// via the SDK's in-process raw-key path. The invitee owns neither the + /// lock's inputs nor its tracking. `keys_map[0]` must be a MASTER-level + /// AUTHENTICATION key. + /// + /// A claim repeated after an earlier one landed is refused by Platform + /// because its outpoint is consumed; the identity at the prospective id is + /// returned when it carries exactly the submitted keys. + /// + /// The caller records the returned identity in its own wallet; nothing is + /// persisted here. The contact-bootstrap is a separate step: on success the + /// caller may send the inviter a contact request. + pub async fn claim_invitation( + &self, + invitation: ParsedInvitation, + keys_map: BTreeMap, + identity_signer: &S, + settings: Option, + ) -> Result + where + S: Signer + Send + Sync, + { + preflight_keys_map(&keys_map)?; + ensure_invitation_network(&invitation, self.network)?; + + // Reconstruct the funding asset-lock proof by refetching the tx. Consensus + // enforces pk↔output, islock↔tx, and identity_id↔outpoint, so the local + // guards are for fast-fail + correct-index selection, not theft + // prevention (a crafted link at worst yields a failed claim). + let fetched = fetch_funding_tx(self, &invitation).await?; + let ReconstructedProof { + primary, + chain_fallback, + out_point, + } = assemble_asset_lock_proof( + fetched.transaction, + fetched.is_chain_locked, + fetched.height, + &invitation, + )?; + let prospective_identity_id = identity_id_for_out_point(out_point); + + // The voucher key signs the asset lock's outer ST signature (ECDSA over + // the credit-output pubkey hash), scrubbed on every exit path by the + // `WipingPrivateKey` guard. + let voucher_priv = WipingPrivateKey(PrivateKey::new(invitation.voucher_key, self.network)); + + // Build the placeholder identity ONCE so both the primary attempt and the + // IS→CL fallback submit the same key set without a `keys_map` clone. + let placeholder = Identity::V0(IdentityV0 { + id: Identifier::default(), + public_keys: keys_map, + balance: 0, + revision: 0, + }); + + // Submit the primary proof first and — only on Platform's stale-islock + // rejection — the ChainLock fallback over the SAME credit output. Each + // submission is wrapped in the CL-height-too-low retry (Platform's + // observed Core tip briefly behind the proof's chain-locked height; + // harmless for an IS proof, which never triggers it). + let placeholder = &placeholder; + let voucher_priv = &voucher_priv; + let submitted = + submit_claim_with_stale_islock_fallback(primary, chain_fallback, move |proof| { + submit_with_cl_height_retry(settings, move |s| { + placeholder.put_to_platform_and_wait_for_response_with_private_key( + self, + proof.clone(), + &voucher_priv.0, + identity_signer, + s, + ) + }) + }) + .await; + + // A lost-ACK duplicate of this very submission ("already in the + // mempool / in chain") is resolved inside the result wait. What + // reaches here is a claim repeated after an earlier one landed, refused + // because its outpoint is consumed: adopt the identity when it carries + // exactly the keys we submitted. + adopt_already_landed_claim(submitted, &out_point, placeholder.public_keys(), || { + Identity::fetch(self, prospective_identity_id) + }) + .await + } +} + +/// The wrong-network guard, the funding-tx fetch and its inspection: what +/// the invitee's read-only queries need, without requiring the tx to be +/// claimable yet (see [`inspect_funding_tx`]). +async fn inspect_invitation_funding( + sdk: &Sdk, + invitation: &ParsedInvitation, +) -> Result { + ensure_invitation_network(invitation, sdk.network)?; + let fetched = fetch_funding_tx(sdk, invitation).await?; + inspect_funding_tx(&fetched.transaction, fetched.is_chain_locked, invitation) +} + +/// Fetch the invitation's funding transaction with the bounded propagation +/// retry (see [`fetch_funding_tx_with_retry`]); a miss after every attempt +/// is an error. +async fn fetch_funding_tx( + sdk: &Sdk, + invitation: &ParsedInvitation, +) -> Result { + fetch_funding_tx_with_retry( + &invitation.funding_txid, + |txid| async move { Ok(sdk.get_transaction(&txid).await?) }, + || rs_dapi_client::transport::sleep(CLAIM_FETCH_RETRY_DELAY), + ) + .await? + .ok_or_else(|| { + invalid( + "invitation funding transaction not found (tried both byte orders across \ + repeated attempts); it may not have propagated to the queried DAPI node yet — \ + retry shortly", + ) + }) +} + +/// Fetch the claim's funding transaction with the bounded propagation retry — +/// the injectable orchestration seam of [`fetch_funding_tx`] (tests +/// script `fetch`/`delay`; production passes `Sdk::get_transaction` and a +/// `tokio::time::sleep`). +/// +/// Two independent concerns are layered here: +/// 1. Byte order — old iOS links carry the txid little-endian, so a +/// canonical miss is retried byte-reversed **within the same attempt** +/// (a compatibility fallback, not a temporal retry). A transport error is +/// NOT masked as a miss — it propagates immediately (it is not a "not +/// indexed yet" signal, so retrying it would only hide it). +/// 2. Propagation lag — InstantSend/ChainLock finality does not guarantee +/// the queried DAPI node has indexed the tx yet, so a fresh invitation +/// can miss on both byte orders purely from propagation delay. Retry up +/// to [`CLAIM_FETCH_MAX_ATTEMPTS`] attempts with one `delay` between +/// attempts — and none after the last (the caller surfaces the miss +/// immediately). +/// +/// Returns `Ok(None)` only after every attempt missed on both byte orders. +async fn fetch_funding_tx_with_retry( + funding_txid: &str, + mut fetch: F, + mut delay: D, +) -> Result, InvitationError> +where + F: FnMut(String) -> FFut, + FFut: std::future::Future, InvitationError>>, + D: FnMut() -> DFut, + DFut: std::future::Future, +{ + for attempt in 0..CLAIM_FETCH_MAX_ATTEMPTS { + if let Some(tx) = fetch(funding_txid.to_string()).await? { + return Ok(Some(tx)); + } + // Reversed lookup is computed lazily on a canonical miss, so a hit + // never depends on the txid being reversible hex. + let reversed = reverse_txid_hex(funding_txid)?; + if let Some(tx) = fetch(reversed).await? { + return Ok(Some(tx)); + } + if attempt + 1 < CLAIM_FETCH_MAX_ATTEMPTS { + delay().await; + } + } + Ok(None) +} + +/// Submit the claim's reconstructed proof with the stale-islock → ChainLock +/// fallback — the injectable orchestration seam of the claim submission (tests +/// script `submit`; production passes the placeholder-identity put wrapped in +/// `submit_with_cl_height_retry`). +/// +/// `primary` is submitted first. Exactly ONE recovery is attempted, and only +/// for the one rejection that indicts the proof kind rather than the claim: +/// `is_instant_lock_proof_invalid` (Platform rejected a stale islock — its +/// signing quorum rotated out, or it is no longer "recent"; Platform's own +/// message asks us to "try chain asset lock proof instead"). On that rejection +/// the ChainLock `chain_fallback` over the SAME credit output is submitted — +/// or, when the funding tx was not yet chain-locked so no fallback could be +/// built, [`InvitationError::NotChainLocked`] surfaces a clear retry signal instead of +/// the raw consensus error. Every other error — from the primary or from the +/// fallback itself — propagates as [`InvitationError::Sdk`] with no +/// further submission. +async fn submit_claim_with_stale_islock_fallback( + primary: AssetLockProof, + chain_fallback: Option, + mut submit: F, +) -> Result +where + F: FnMut(AssetLockProof) -> Fut, + Fut: std::future::Future>, +{ + match submit(primary).await { + Ok(created) => Ok(created), + Err(e) if is_instant_lock_proof_invalid(&e) => { + let Some(chain_proof) = chain_fallback else { + return Err(InvitationError::NotChainLocked( + "invitation islock proof was rejected by Platform (stale — quorum \ + rotated or no longer recent) and the funding transaction is not yet \ + chain-locked, so no ChainLock fallback is possible; retry once the \ + funding block is chain-locked" + .to_string(), + )); + }; + tracing::warn!( + "invitation IS-lock proof rejected by Platform on claim; retrying with a \ + ChainLock proof over the same funding outpoint" + ); + submit(chain_proof).await.map_err(InvitationError::Sdk) + } + Err(e) => Err(InvitationError::Sdk(e)), + } +} + +/// Refuse a link whose voucher key is for the other network before any +/// network work: a testnet WIF is a valid key on the wrong chain, so it would +/// otherwise surface as a confusing funding-tx fetch miss. +fn ensure_invitation_network( + invitation: &ParsedInvitation, + wallet_network: Network, +) -> Result<(), InvitationError> { + if wif_network_matches(invitation.voucher_key_network, wallet_network) { + Ok(()) + } else { + Err(InvitationError::NetworkMismatch { + invitation: invitation.voucher_key_network, + wallet: wallet_network, + }) + } +} + +/// The identity id Platform derives from an asset-lock outpoint. Both proof +/// kinds hash only the outpoint, so the ChainLock derivation (which ignores +/// its height) gives the id for either. +fn identity_id_for_out_point(out_point: OutPoint) -> Identifier { + ChainAssetLockProof::new(0, out_point.into()).create_identifier() +} + +/// Recognize a claim Platform already executed. A claim repeated after an +/// earlier attempt landed (reported as a failure, or interrupted) is refused +/// because its outpoint is consumed; the identity at the prospective id is +/// ours when it carries exactly the keys we submitted, and is then the +/// claim's result. Anything else (no identity, someone else's, a failed +/// lookup) reports the original rejection. +/// +/// `fetch` looks up the identity at the prospective id: the injectable seam +/// (production passes `Identity::fetch`). +async fn adopt_already_landed_claim( + submitted: Result, + out_point: &OutPoint, + submitted_keys: &BTreeMap, + fetch: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future, Error>>, +{ + let error = match submitted { + Err(InvitationError::Sdk(error)) if is_asset_lock_already_consumed(&error, out_point) => { + error + } + other => return other, + }; + match fetch().await { + Ok(Some(identity)) if identity_carries_keys(&identity, submitted_keys) => { + tracing::info!( + identity_id = %identity.id(), + "invitation claim was already accepted by Platform; adopting its identity" + ); + Ok(identity) + } + Ok(_) => Err(InvitationError::Sdk(error)), + Err(lookup) => { + tracing::warn!( + error = %lookup, + "could not look up the identity of a consumed invitation; reporting the rejection" + ); + Err(InvitationError::Sdk(error)) + } + } +} + +/// The identity carries exactly `expected` public keys — same ids, same key +/// data. Key data is what only our signer could have produced, so a match +/// means the identity was created by our claim, not by a finder of the link. +fn identity_carries_keys( + identity: &Identity, + expected: &BTreeMap, +) -> bool { + let actual = identity.public_keys(); + actual.len() == expected.len() + && expected.iter().all(|(id, key)| { + actual.get(id).is_some_and(|found| { + found.data() == key.data() && found.key_type() == key.key_type() + }) + }) +} + +/// The claim's reconstructed funding proof, plus an optional ChainLock fallback. +/// +/// `primary` is submitted first: an [`AssetLockProof::Instant`] when the link +/// carried an islock (the fast path), or an [`AssetLockProof::Chain`] when it did +/// not. `chain_fallback` is a ChainLock proof over the SAME credit output, +/// populated ONLY when `primary` is InstantSend AND the funding tx is already +/// chain-locked — [`Sdk::claim_invitation`]'s stale-islock recovery +/// resubmits it if Platform rejects the primary IS proof with +/// `InvalidInstantAssetLockProofSignatureError`. It is `None` when the primary is +/// already a ChainLock proof (nothing to fall back to) or when the funding tx is +/// not yet chain-locked (no ChainLock proof can be built — the claim must be +/// retried once the block confirms). +#[derive(Debug)] +struct ReconstructedProof { + primary: AssetLockProof, + chain_fallback: Option, + /// The voucher's credit outpoint both proofs spend. + out_point: OutPoint, +} + +/// What an invitation's funding transaction says before any proof is built. +#[derive(Debug)] +struct InvitationFunding { + /// The voucher's credit outpoint; the claim's identity id derives from it. + out_point: OutPoint, + /// Value of that credit output (duffs): what the invitation is worth. + credit_output_duffs: u64, + /// The link's islock, decoded and checked to lock this transaction. + instant_lock: Option, + /// The funding transaction is chain-locked. + is_chain_locked: bool, +} + +/// Check a fetched funding transaction against the link and select the +/// voucher's credit output: the pure core shared by the claim's proof +/// assembly and the read-only queries. Validates the tx is the funding tx +/// (either byte order), selects the voucher's credit output, and decodes the +/// link's islock (checking it locks this tx). Chain-lock finality is reported, +/// not required: only building a ChainLock proof needs it. +fn inspect_funding_tx( + transaction: &Transaction, + is_chain_locked: bool, + invitation: &ParsedInvitation, +) -> Result { + // Fail-fast: the fetched tx must actually be the funding tx (either byte + // order). DAPI returns whatever tx matches the id we asked for, so this + // guards a backend that answers with an unrelated tx. + let fetched_txid = transaction.txid().to_string(); + let reversed_txid = reverse_txid_hex(&invitation.funding_txid).ok(); + if fetched_txid != invitation.funding_txid + && reversed_txid.as_deref() != Some(fetched_txid.as_str()) + { + return Err(InvitationError::Invalid( + "fetched transaction id does not match the invitation funding txid".to_string(), + )); + } + + // Select the funded credit output the voucher key controls (not index 0 + // — a legacy invite's credit output need not be first). + let (output_index, credit_output) = + voucher_credit_output(transaction, &invitation.voucher_key)?; + + let instant_lock = match &invitation.islock_hex { + Some(islock_hex) => { + let islock_bytes = hex::decode(islock_hex).map_err(|e| { + InvitationError::Invalid(format!("invitation islock is not valid hex: {e}")) + })?; + // The hex is not self-describing; the modern deterministic islock + // (ISDLOCK) carries its version byte, which is exactly what the + // consensus decoder reads first. + let instant_lock = InstantLock::consensus_decode(&mut islock_bytes.as_slice()) + .map_err(|e| { + InvitationError::Invalid(format!("invitation islock could not be decoded: {e}")) + })?; + if instant_lock.txid != transaction.txid() { + return Err(InvitationError::Invalid( + "invitation islock does not lock the funding transaction".to_string(), + )); + } + Some(instant_lock) + } + None => None, + }; + + Ok(InvitationFunding { + out_point: OutPoint::new(transaction.txid(), output_index), + credit_output_duffs: credit_output.value, + instant_lock, + is_chain_locked, + }) +} + +/// Assemble the asset-lock proof from an already-fetched funding transaction — the +/// pure, testable core of the claim reconstruction (the fetch/retry lives in +/// [`fetch_funding_tx`]). Checks the tx with [`inspect_funding_tx`], then +/// builds an InstantSend proof (link carried an islock) or a ChainLock proof +/// (islock absent), requiring chain-lock finality for the latter. +/// +/// When an islock is present AND the funding tx is already chain-locked, the +/// returned [`ReconstructedProof`] also carries a `chain_fallback` ChainLock proof +/// over the same credit output, so the caller can recover from a stale islock that +/// Platform rejects without refetching the tx. +fn assemble_asset_lock_proof( + transaction: Transaction, + is_chain_locked: bool, + height: u32, + invitation: &ParsedInvitation, +) -> Result { + let InvitationFunding { + out_point, + instant_lock, + .. + } = inspect_funding_tx(&transaction, is_chain_locked, invitation)?; + + // A ChainLock proof over the selected credit output. Buildable only once the + // funding block is chain-locked; `height` is the tx's mined height (the + // `ChainAssetLockProof`'s `core_chain_locked_height`). Reused both as the + // primary for an islock-less invite and as the stale-islock fallback. + let chain_lock_proof = + || AssetLockProof::Chain(ChainAssetLockProof::new(height, out_point.into())); + + match instant_lock { + Some(instant_lock) => { + // Fast path: submit the InstantSend proof. If the islock is stale + // (quorum rotated / no longer "recent") Platform rejects it, and the + // claim falls back to `chain_fallback` — available only when the + // funding tx is already chain-locked (the usual case by claim time, + // since the voucher was funded minutes-to-hours earlier). + let chain_fallback = is_chain_locked.then(chain_lock_proof); + let primary = AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + transaction, + out_point.vout, + )); + Ok(ReconstructedProof { + primary, + chain_fallback, + out_point, + }) + } + None => { + // ChainLock invite: the proof references the outpoint + a chain-locked + // core height. Require the funding tx to be chain-locked so the proof + // proves finality; the inviter/invitee retries once the block is + // chain-locked otherwise. There is no separate fallback — this IS the + // ChainLock proof. + if !is_chain_locked { + return Err(InvitationError::Invalid( + "chainlock invitation funding transaction is not yet chain-locked; \ + retry once it confirms" + .to_string(), + )); + } + Ok(ReconstructedProof { + primary: chain_lock_proof(), + chain_fallback: None, + out_point, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::dashcore::consensus::Encodable; + use dpp::dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + + fn voucher() -> SecretKey { + SecretKey::from_secret_bytes([0x11u8; 32]).expect("valid scalar") + } + + fn inviter_info() -> InviterInfo { + InviterInfo { + username: Some("alice".to_string()), + display_name: Some("Alice Example".to_string()), + avatar_url: Some("https://example.com/a b.png?x=1".to_string()), + } + } + + /// Build an asset-lock tx whose credit output at `index` pays the voucher + /// key (and `index` decoy outputs before it that do not). + fn asset_lock_tx_paying_voucher_at(key: &SecretKey, index: usize) -> Transaction { + let decoy = SecretKey::from_secret_bytes([0x22u8; 32]).unwrap(); + let mut credit_outputs = Vec::new(); + for _ in 0..index { + credit_outputs.push(TxOut { + value: 50_000, + script_pubkey: voucher_credit_script(&decoy), + }); + } + credit_outputs.push(TxOut { + value: 100_000, + script_pubkey: voucher_credit_script(key), + }); + let payload = AssetLockPayload { + version: 1, + credit_outputs, + }; + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), + } + } + + fn instant_proof_paying_voucher() -> AssetLockProof { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 0); + AssetLockProof::Instant(InstantAssetLockProof::new(InstantLock::default(), tx, 0)) + } + + #[test] + fn wif_round_trip_preserves_compression_and_network() { + for (network, first_byte) in [(Network::Mainnet, 204u8), (Network::Testnet, 239u8)] { + let wif = PrivateKey::new(voucher(), network).to_wif(); + let decoded = PrivateKey::from_wif(&wif).expect("wif decodes"); + assert!(decoded.compressed, "voucher WIF must be compressed"); + assert_eq!(decoded.network, network, "network preserved"); + assert_eq!(decoded.inner.to_secret_bytes(), voucher().to_secret_bytes()); + // Network byte matches bitcoinj/legacy (0xCC mainnet, 0xEF testnet). + let raw = dpp::dashcore::base58::decode(&wif).unwrap(); + assert_eq!(raw[0], first_byte); + } + } + + #[test] + fn encode_parse_round_trip_with_inviter() { + let proof = instant_proof_paying_voucher(); + let info = inviter_info(); + let uri = encode_invitation_uri(&voucher(), Network::Testnet, &proof, Some(&info)) + .expect("encode"); + assert!(uri.starts_with("dashpay://invite?")); + + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert_eq!( + parsed.voucher_key.to_secret_bytes(), + voucher().to_secret_bytes() + ); + assert_eq!(parsed.inviter, Some(info)); + assert!(parsed.islock_hex.is_some()); + // The parsed txid matches the proof's transaction id (big-endian). + let expected_txid = match &proof { + AssetLockProof::Instant(i) => i.transaction().txid().to_string(), + _ => unreachable!(), + }; + assert_eq!(parsed.funding_txid, expected_txid); + } + + #[test] + fn encode_parse_round_trip_pure_voucher_no_inviter() { + let proof = instant_proof_paying_voucher(); + let uri = + encode_invitation_uri(&voucher(), Network::Mainnet, &proof, None).expect("encode"); + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert!(parsed.inviter.is_none(), "du-less link ⇒ no inviter"); + assert!(!uri.contains("du="), "pure voucher emits no du"); + } + + /// Params in a non-canonical order must still parse (field-level, not + /// byte-level, interop — the two legacy wallets differ in order). + #[test] + fn parse_is_order_independent() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!("dashpay://invite?islock=deadbeef&pk={wif}&du=bob&assetlocktx=aabbcc"); + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert_eq!(parsed.funding_txid, "aabbcc"); + assert_eq!(parsed.islock_hex.as_deref(), Some("deadbeef")); + assert_eq!( + parsed.inviter.as_ref().unwrap().username.as_deref(), + Some("bob") + ); + } + + /// The `https://invitations.dashpay.io/applink` host is accepted as a + /// first-class alternative to the custom scheme (iOS emits it). + #[test] + fn parse_accepts_applink_host() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!( + "https://invitations.dashpay.io/applink?du=carol&assetlocktx=aabb&pk={wif}&islock=cc" + ); + let parsed = parse_invitation_uri(&uri).expect("parse applink"); + assert_eq!( + parsed.inviter.as_ref().unwrap().username.as_deref(), + Some("carol") + ); + assert_eq!(parsed.funding_txid, "aabb"); + } + + /// `islock` present / absent / `"null"`: only a real hex value yields + /// `Some`; both a missing param and the literal `"null"` yield `None` + /// (a ChainLock-confirmed invite Android's own validator accepts). + #[test] + fn parse_islock_present_absent_and_null() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let present = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=00aa11"); + assert_eq!( + parse_invitation_uri(&present) + .unwrap() + .islock_hex + .as_deref(), + Some("00aa11") + ); + + let absent = format!("dashpay://invite?assetlocktx=aa&pk={wif}"); + assert!(parse_invitation_uri(&absent).unwrap().islock_hex.is_none()); + + let null = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=null"); + assert!( + parse_invitation_uri(&null).unwrap().islock_hex.is_none(), + "islock=null must be treated as no instant lock" + ); + } + + /// A `du`-less link still parses (iOS accepts du-less links). + #[test] + fn parse_accepts_du_less_link() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=bb"); + let parsed = parse_invitation_uri(&uri).expect("parse"); + assert!(parsed.inviter.is_none()); + } + + #[test] + fn parse_rejects_missing_pk_or_assetlocktx() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + // No pk. + assert!(parse_invitation_uri("dashpay://invite?assetlocktx=aa").is_err()); + // No assetlocktx. + assert!(parse_invitation_uri(&format!("dashpay://invite?pk={wif}")).is_err()); + // Blank assetlocktx is treated as missing. + assert!(parse_invitation_uri(&format!("dashpay://invite?assetlocktx=&pk={wif}")).is_err()); + } + + #[test] + fn parse_rejects_wrong_scheme_and_host() { + assert!(parse_invitation_uri("https://example.com/foo?pk=x").is_err()); + assert!(parse_invitation_uri("dashpay://contact?pk=x").is_err()); + } + + #[test] + fn parse_rejects_uncompressed_wif() { + let uncompressed = PrivateKey::new_uncompressed(voucher(), Network::Testnet).to_wif(); + let uri = format!("dashpay://invite?assetlocktx=aa&pk={uncompressed}"); + let err = parse_invitation_uri(&uri).unwrap_err(); + assert!(err.to_string().contains("compressed")); + } + + #[test] + fn parse_rejects_bad_wif() { + let err = parse_invitation_uri("dashpay://invite?assetlocktx=aa&pk=not-a-wif").unwrap_err(); + assert!(err.to_string().contains("WIF")); + } + + #[test] + fn parse_rejects_oversized_uri() { + let huge = format!("dashpay://invite?pk={}", "z".repeat(MAX_INVITATION_URI_LEN)); + let err = parse_invitation_uri(&huge).unwrap_err(); + assert!(err.to_string().contains("too long")); + } + + /// A hand-crafted Android-style link (params in Android's emit order, with + /// display-name + avatar-url) parses to the right fields. + #[test] + fn parse_android_style_link() { + let wif = PrivateKey::new(voucher(), Network::Mainnet).to_wif(); + // Android order: du, assetlocktx, pk, islock, display-name, avatar-url. + let uri = format!( + "dashpay://invite?du=satoshi&assetlocktx={txid}&pk={wif}&islock={islock}&display-name=Sat%20Oshi&avatar-url=https%3A%2F%2Fimg.example%2Fa.png", + txid = "e8b43025641eea4fd21190f01bd870ef90f1a8b199d8fc3376c5b62c0b1a179d", + islock = "01" + ); + let parsed = parse_invitation_uri(&uri).expect("parse android link"); + assert_eq!( + parsed.funding_txid, + "e8b43025641eea4fd21190f01bd870ef90f1a8b199d8fc3376c5b62c0b1a179d" + ); + assert_eq!(parsed.islock_hex.as_deref(), Some("01")); + let inviter = parsed.inviter.as_ref().expect("inviter"); + assert_eq!(inviter.username.as_deref(), Some("satoshi")); + assert_eq!(inviter.display_name.as_deref(), Some("Sat Oshi")); + assert_eq!( + inviter.avatar_url.as_deref(), + Some("https://img.example/a.png") + ); + } + + /// The voucher output is selected by pk↔script match, not hard-coded to 0. + #[test] + fn voucher_output_index_selects_matching_output() { + let key = voucher(); + // Voucher output sits at index 2, behind two decoy outputs. + let tx = asset_lock_tx_paying_voucher_at(&key, 2); + assert_eq!(voucher_output_index(&tx, &key).unwrap(), 2); + } + + /// The credit output comes back with its index, so its value is the + /// voucher's, not a decoy's. + #[test] + fn should_return_the_voucher_credit_output_with_its_index() { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 2); + let (index, output) = voucher_credit_output(&tx, &key).unwrap(); + assert_eq!(index, 2); + assert_eq!(output.value, 100_000, "the decoys before it carry 50_000"); + assert_eq!(output.script_pubkey, voucher_credit_script(&key)); + } + + #[test] + fn voucher_output_index_rejects_no_match() { + let key = voucher(); + let other = SecretKey::from_secret_bytes([0x33u8; 32]).unwrap(); + let tx = asset_lock_tx_paying_voucher_at(&other, 0); + let err = voucher_output_index(&tx, &key).unwrap_err(); + assert!(err.to_string().contains("does not control")); + } + + #[test] + fn voucher_output_index_rejects_non_asset_lock_tx() { + let key = voucher(); + let tx = Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + assert!(voucher_output_index(&tx, &key).is_err()); + } + + /// A `du`-less link that still carries `display-name`/`avatar-url` must + /// surface them (the reference wallets emit those independently of `du`). + #[test] + fn parse_du_less_link_keeps_metadata() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = format!( + "dashpay://invite?assetlocktx=aa&pk={wif}&display-name=No%20User&avatar-url=https%3A%2F%2Fimg%2Fx.png" + ); + let parsed = parse_invitation_uri(&uri).expect("parse"); + let inviter = parsed.inviter.as_ref().expect("metadata-only inviter"); + assert!(inviter.username.is_none(), "no du ⇒ no username"); + assert_eq!(inviter.display_name.as_deref(), Some("No User")); + assert_eq!(inviter.avatar_url.as_deref(), Some("https://img/x.png")); + } + + /// A duplicated required key is ambiguous (iOS binds the last, Android the + /// first) — reject rather than claim a wallet-dependent key. + #[test] + fn parse_rejects_duplicate_required_keys() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let other = PrivateKey::new( + SecretKey::from_secret_bytes([0x44u8; 32]).unwrap(), + Network::Testnet, + ) + .to_wif(); + let dup_pk = format!("dashpay://invite?assetlocktx=aa&pk={wif}&pk={other}"); + assert!(parse_invitation_uri(&dup_pk) + .unwrap_err() + .to_string() + .contains("duplicated")); + + let dup_tx = format!("dashpay://invite?assetlocktx=aa&assetlocktx=bb&pk={wif}"); + assert!(parse_invitation_uri(&dup_tx) + .unwrap_err() + .to_string() + .contains("duplicated")); + } + + /// The scheme/host gate must be anchored: a suffixed scheme host + /// (`dashpay://inviteXYZ`) and a decoy URL that merely *mentions* the applink + /// host in its own query must both be rejected — otherwise the decoy's query + /// (with a `pk`/`assetlocktx`) would be parsed. + #[test] + fn parse_rejects_decoy_scheme_and_host() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + // Suffixed authority — not exactly `invite`. + let suffixed = format!("dashpay://inviteXYZ?assetlocktx=aa&pk={wif}"); + assert!(parse_invitation_uri(&suffixed).is_err()); + // Decoy host: the real host is evil.example; the applink string only + // appears inside the decoy's own query value. + let decoy = format!( + "https://evil.example/r?next=invitations.dashpay.io/applink&assetlocktx=aa&pk={wif}" + ); + assert!(parse_invitation_uri(&decoy).is_err()); + // Wrong path on the right host. + let wrong_path = format!("https://invitations.dashpay.io/evil?assetlocktx=aa&pk={wif}"); + assert!(parse_invitation_uri(&wrong_path).is_err()); + } + + /// WIF network compatibility is mainnet-vs-not: a testnet WIF is rejected on + /// a mainnet wallet, but accepted on any testnet-family wallet. + #[test] + fn wif_network_matches_is_mainnet_vs_testnet_family() { + assert!(wif_network_matches(Network::Mainnet, Network::Mainnet)); + assert!(wif_network_matches(Network::Testnet, Network::Testnet)); + // WIF decodes 0xEF to Testnet; a devnet/regtest wallet must still accept. + assert!(wif_network_matches(Network::Testnet, Network::Devnet)); + assert!(wif_network_matches(Network::Testnet, Network::Regtest)); + // Cross mainnet/testnet is rejected (the wrong-chain paste). + assert!(!wif_network_matches(Network::Testnet, Network::Mainnet)); + assert!(!wif_network_matches(Network::Mainnet, Network::Testnet)); + } + + // --- assemble_asset_lock_proof: the claim-time proof reconstruction guards --- + + fn parsed( + key: SecretKey, + funding_txid: String, + islock_hex: Option, + ) -> ParsedInvitation { + ParsedInvitation { + voucher_key: key, + voucher_key_network: Network::Testnet, + funding_txid, + islock_hex, + inviter: None, + } + } + + /// A backend answering with an unrelated tx (id matches neither byte order) is + /// rejected before any proof is built. + #[test] + fn assemble_rejects_txid_mismatch() { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 0); + let inv = parsed(key, "00".repeat(32), None); + let err = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap_err(); + assert!(format!("{err}").contains("does not match")); + } + + /// A ChainLock invite (no islock) whose funding tx is not yet chain-locked is + /// rejected — the proof must prove finality. + #[test] + fn assemble_chainlock_requires_chain_lock() { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 0); + let txid = tx.txid().to_string(); + let inv = parsed(key, txid, None); + let err = assemble_asset_lock_proof(tx, false, 100, &inv).unwrap_err(); + assert!(format!("{err}").contains("not yet chain-locked")); + } + + /// A chain-locked ChainLock invite (no islock) assembles a ChainLock proof at + /// the tx's voucher output, with no separate fallback (the primary IS the CL + /// proof). + #[test] + fn assemble_chainlock_ok_when_locked() { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 0); + let txid = tx.txid().to_string(); + let inv = parsed(key, txid, None); + let reconstructed = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); + assert!(matches!(reconstructed.primary, AssetLockProof::Chain(_))); + assert!( + reconstructed.chain_fallback.is_none(), + "an islock-less chainlock invite has no separate fallback" + ); + } + + /// An islock that locks the funding tx, with the tx already chain-locked, + /// yields an InstantSend primary (fast path) PLUS a ChainLock fallback over the + /// same credit output — the stale-islock recovery the claim path submits if + /// Platform rejects the IS proof with `InvalidInstantAssetLockProofSignatureError`. + #[test] + fn assemble_islock_present_and_chainlocked_carries_chain_fallback() { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 0); + let txid = tx.txid().to_string(); + let islock = InstantLock { + txid: tx.txid(), + ..Default::default() + }; + let mut islock_bytes = Vec::new(); + islock.consensus_encode(&mut islock_bytes).unwrap(); + let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); + let reconstructed = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); + assert!( + matches!(reconstructed.primary, AssetLockProof::Instant(_)), + "the islock fast path must be tried first" + ); + assert!( + matches!(reconstructed.chain_fallback, Some(AssetLockProof::Chain(_))), + "a chain-locked funding tx must carry a ChainLock fallback for stale-islock recovery" + ); + } + + /// An islock that locks the funding tx, but the tx NOT yet chain-locked, yields + /// the IS primary with NO fallback: if that islock is later rejected there is + /// no ChainLock proof to fall back to, and the claim must be retried once the + /// block confirms. + #[test] + fn assemble_islock_present_not_chainlocked_has_no_fallback() { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 0); + let txid = tx.txid().to_string(); + let islock = InstantLock { + txid: tx.txid(), + ..Default::default() + }; + let mut islock_bytes = Vec::new(); + islock.consensus_encode(&mut islock_bytes).unwrap(); + let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); + let reconstructed = assemble_asset_lock_proof(tx, false, 100, &inv).unwrap(); + assert!(matches!(reconstructed.primary, AssetLockProof::Instant(_))); + assert!( + reconstructed.chain_fallback.is_none(), + "an un-chain-locked funding tx cannot produce a ChainLock fallback" + ); + } + + /// The prospective identity id is derived from the *selected* credit + /// output's outpoint. Selection itself is pinned next to + /// `voucher_output_index`; what matters here is that the id follows it — a + /// voucher sitting behind a decoy must not yield the index-0 id, or the + /// "has this invitation been used?" check answers about a stranger's + /// identity and reports a perfectly good voucher as spent. + #[test] + fn prospective_id_follows_the_selected_credit_output() { + let key = voucher(); + let decoy = SecretKey::from_secret_bytes([0x22u8; 32]).unwrap(); + let payload = AssetLockPayload { + version: 1, + credit_outputs: vec![ + TxOut { + value: 100_000, + script_pubkey: voucher_credit_script(&decoy), + }, + TxOut { + value: 25_000_000, + script_pubkey: voucher_credit_script(&key), + }, + ], + }; + let tx = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), + }; + let txid = tx.txid(); + let inv = parsed(key, txid.to_string(), None); + + let funding = inspect_funding_tx(&tx, true, &inv).unwrap(); + assert_eq!( + funding.credit_output_duffs, 25_000_000, + "the amount must come from the voucher's output, not the decoy at index 0" + ); + let proof = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); + let id = proof.primary.create_identifier().unwrap(); + assert_eq!( + identity_id_for_out_point(funding.out_point), + id, + "the read-only queries must derive the id the claim creates" + ); + + let from_index_0 = + ChainAssetLockProof::new(100, OutPoint::new(txid, 0).into()).create_identifier(); + let from_index_1 = + ChainAssetLockProof::new(100, OutPoint::new(txid, 1).into()).create_identifier(); + + assert_ne!(id, from_index_0, "id must not come from credit output 0"); + assert_eq!(id, from_index_1); + } + + /// The prospective id is a pure function of the asset-lock OUTPOINT, so it + /// carries no information about whether that lock has been consumed. + /// + /// This is why the claimed-check is one-way. A voucher claimed normally + /// creates the identity at this id, and the check sees it. A voucher + /// RECLAIMED into an existing identity — an `IdentityTopUp` the inviter + /// funds with the voucher's asset lock — spends the very same outpoint but + /// creates nothing here, so the check still finds no identity while a claim + /// would fail deterministically. + /// + /// Pinned as an executable fact because the id derivation is what a reader + /// would otherwise assume encodes "spent": it does not, and Platform + /// exposes no client query for spent asset locks to fill the gap. + #[test] + fn prospective_id_is_outpoint_derived_and_says_nothing_about_consumption() { + let key = voucher(); + let payload = AssetLockPayload { + version: 1, + credit_outputs: vec![TxOut { + value: 100_000, + script_pubkey: voucher_credit_script(&key), + }], + }; + let tx = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType(payload)), + }; + let txid = tx.txid(); + let inv = parsed(key, txid.to_string(), None); + + let id = assemble_asset_lock_proof(tx, true, 100, &inv) + .unwrap() + .primary + .create_identifier() + .unwrap(); + + // Nothing but (txid, vout) feeds it — the same value a reclaim would + // leave behind untouched. + let from_outpoint = + ChainAssetLockProof::new(100, OutPoint::new(txid, 0).into()).create_identifier(); + assert_eq!( + id, from_outpoint, + "the id must be derivable from the outpoint alone, which is exactly \ + why its absence cannot prove the lock is unspent" + ); + } + + /// A test identity key: `byte` fills the key data, so two keys differ + /// exactly when their bytes do. + fn test_key(id: KeyID, byte: u8) -> IdentityPublicKey { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::KeyType; + use dpp::platform_value::BinaryData; + + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }) + } + + fn identity_with_keys(keys: BTreeMap) -> Identity { + Identity::V0(IdentityV0 { + id: Identifier::default(), + public_keys: keys, + balance: 0, + revision: 0, + }) + } + + fn our_keys() -> BTreeMap { + [(0, test_key(0, 1)), (1, test_key(1, 2))].into() + } + + /// A claim that already landed is recognized as ours only by its keys: + /// the same key set is ours, anything else (a finder of the link claiming + /// first) is not. + #[test] + fn should_recognize_a_claimed_identity_only_by_our_keys() { + let ours = our_keys(); + assert!(identity_carries_keys( + &identity_with_keys(ours.clone()), + &ours + )); + let theirs: BTreeMap = + [(0, test_key(0, 9)), (1, test_key(1, 2))].into(); + assert!(!identity_carries_keys(&identity_with_keys(theirs), &ours)); + let fewer: BTreeMap = [(0, test_key(0, 1))].into(); + assert!(!identity_carries_keys(&identity_with_keys(fewer), &ours)); + } + + /// A testnet link in a mainnet wallet is refused with the typed + /// `InvitationError::NetworkMismatch`. + #[test] + fn should_refuse_an_invitation_for_the_other_network() { + let inv = parsed(voucher(), "00".repeat(32), None); + assert!(ensure_invitation_network(&inv, Network::Testnet).is_ok()); + assert!(matches!( + ensure_invitation_network(&inv, Network::Mainnet), + Err(InvitationError::NetworkMismatch { + invitation: Network::Testnet, + wallet: Network::Mainnet, + }) + )); + } + + /// A ChainLock-only link whose funding tx is not chain-locked yet cannot + /// be claimed, but the read-only view still reads its amount and outpoint + /// and reports the missing ChainLock instead of failing. + #[test] + fn should_inspect_a_chainlock_invitation_before_it_is_chain_locked() { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 0); + let txid = tx.txid(); + let inv = parsed(key, txid.to_string(), None); + + let funding = inspect_funding_tx(&tx, false, &inv).unwrap(); + assert_eq!(funding.credit_output_duffs, 100_000); + assert_eq!(funding.out_point, OutPoint::new(txid, 0)); + assert!(funding.instant_lock.is_none()); + assert!(!funding.is_chain_locked); + assert!( + assemble_asset_lock_proof(tx, false, 100, &inv).is_err(), + "the claim itself still waits for the ChainLock" + ); + } + + /// The InstantSend read-only view carries the decoded islock, and its id + /// matches the claim's InstantSend primary. + #[test] + fn should_inspect_an_instant_invitation_with_the_claims_identity_id() { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 0); + let txid = tx.txid().to_string(); + let islock = InstantLock { + txid: tx.txid(), + ..Default::default() + }; + let mut islock_bytes = Vec::new(); + islock.consensus_encode(&mut islock_bytes).unwrap(); + let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); + + let funding = inspect_funding_tx(&tx, true, &inv).unwrap(); + assert!(funding.instant_lock.is_some()); + assert_eq!(funding.credit_output_duffs, 100_000); + let primary = assemble_asset_lock_proof(tx, true, 100, &inv) + .unwrap() + .primary; + assert_eq!( + identity_id_for_out_point(funding.out_point), + primary.create_identifier().unwrap() + ); + } + + // --- adopt_already_landed_claim: recognizing our own earlier claim --- + + mod claim_adoption { + use std::cell::Cell; + + use super::super::adopt_already_landed_claim; + use super::super::InvitationError; + use super::{identity_with_keys, our_keys, test_key}; + use dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError; + use dpp::consensus::basic::BasicError; + use dpp::consensus::ConsensusError; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::{OutPoint, Txid}; + use dpp::identity::{Identity, IdentityPublicKey, KeyID}; + use std::collections::BTreeMap; + + fn out_point() -> OutPoint { + OutPoint::new(Txid::from_byte_array([7u8; 32]), 1) + } + + /// Platform's consumed-outpoint rejection for `out_point`. + fn consumed(out_point: OutPoint) -> InvitationError { + InvitationError::Sdk(crate::Error::Protocol(dpp::ProtocolError::ConsensusError( + Box::new(ConsensusError::BasicError( + BasicError::IdentityAssetLockTransactionOutPointAlreadyConsumedError( + IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( + out_point.txid, + out_point.vout as usize, + ), + ), + )), + ))) + } + + fn is_consumed(result: &Result) -> bool { + matches!(result, Err(InvitationError::Sdk(crate::Error::Protocol(_)))) + } + + /// Run the seam with a scripted lookup, counting how often it is called. + async fn adopt( + submitted: Result, + lookup: Result, crate::Error>, + ) -> (Result, u32) { + let calls = Cell::new(0); + let keys = our_keys(); + let result = adopt_already_landed_claim(submitted, &out_point(), &keys, || { + calls.set(calls.get() + 1); + async { lookup } + }) + .await; + (result, calls.get()) + } + + #[tokio::test] + async fn should_pass_a_successful_claim_through_without_a_lookup() { + let (result, lookups) = adopt(Ok(identity_with_keys(our_keys())), Ok(None)).await; + assert!(result.is_ok()); + assert_eq!(lookups, 0); + } + + #[tokio::test] + async fn should_adopt_our_identity_behind_a_consumed_outpoint() { + let (result, lookups) = adopt( + Err(consumed(out_point())), + Ok(Some(identity_with_keys(our_keys()))), + ) + .await; + assert!(result.is_ok(), "our own landed claim is the claim's result"); + assert_eq!(lookups, 1); + } + + #[tokio::test] + async fn should_report_the_rejection_when_someone_else_claimed_first() { + let theirs: BTreeMap = [(0, test_key(0, 9))].into(); + let (result, _) = adopt( + Err(consumed(out_point())), + Ok(Some(identity_with_keys(theirs))), + ) + .await; + assert!(is_consumed(&result)); + } + + /// A reclaim top-up consumes the outpoint without creating the + /// identity: nothing to adopt. + #[tokio::test] + async fn should_report_the_rejection_when_no_identity_exists() { + let (result, _) = adopt(Err(consumed(out_point())), Ok(None)).await; + assert!(is_consumed(&result)); + } + + /// A failed lookup must not replace the rejection that explains the + /// failure. + #[tokio::test] + async fn should_keep_the_rejection_when_the_lookup_fails() { + let (result, _) = adopt( + Err(consumed(out_point())), + Err(crate::Error::Generic("timeout".to_string())), + ) + .await; + assert!(is_consumed(&result)); + } + + /// A consumed report for another outpoint is not about this claim. + #[tokio::test] + async fn should_ignore_a_consumed_report_for_another_outpoint() { + let other = OutPoint::new(Txid::from_byte_array([8u8; 32]), 1); + let (result, lookups) = adopt( + Err(consumed(other)), + Ok(Some(identity_with_keys(our_keys()))), + ) + .await; + assert!(is_consumed(&result)); + assert_eq!(lookups, 0); + } + + #[tokio::test] + async fn should_pass_unrelated_errors_through_without_a_lookup() { + let (result, lookups) = adopt( + Err(InvitationError::Sdk(crate::Error::Generic( + "unrelated".to_string(), + ))), + Ok(Some(identity_with_keys(our_keys()))), + ) + .await; + assert!(matches!( + result, + Err(InvitationError::Sdk(crate::Error::Generic(_))) + )); + assert_eq!(lookups, 0); + } + } + + /// An islock that locks a DIFFERENT tx than the funding tx is rejected (the + /// txid-binding guard), so a link can't pair a valid islock with a foreign tx. + #[test] + fn assemble_rejects_islock_for_wrong_tx() { + let key = voucher(); + let tx = asset_lock_tx_paying_voucher_at(&key, 0); + let txid = tx.txid().to_string(); + // A default islock carries a zeroed txid, which won't equal the funding tx. + let mut islock_bytes = Vec::new(); + InstantLock::default() + .consensus_encode(&mut islock_bytes) + .unwrap(); + let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); + let err = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap_err(); + assert!(format!("{err}").contains("does not lock the funding transaction")); + } + + // --- fetch_funding_tx_with_retry: the claim propagation-retry seam --- + + mod fetch_retry { + use std::collections::VecDeque; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::{Arc, Mutex}; + + use super::super::InvitationError; + use super::super::{ + fetch_funding_tx_with_retry, reverse_txid_hex, CLAIM_FETCH_MAX_ATTEMPTS, + }; + + /// A canonical 64-hex txid whose byte-reversed form differs from it. + fn canonical_txid() -> String { + format!("{}{}", "11".repeat(31), "22") + } + + /// Scripted fetch: pops the next result per call and records the txid + /// each call asked for; plus a counter-only delay. + #[allow(clippy::type_complexity)] + fn harness( + script: Vec, InvitationError>>, + ) -> ( + Arc, InvitationError>>>>, + Arc>>, + Arc, + ) { + ( + Arc::new(Mutex::new(script.into_iter().collect())), + Arc::new(Mutex::new(Vec::new())), + Arc::new(AtomicU32::new(0)), + ) + } + + async fn run( + script: Vec, InvitationError>>, + ) -> (Result, InvitationError>, Vec, u32) { + let canonical = canonical_txid(); + let (queue, calls, delays) = harness(script); + let (queue_f, calls_f, delays_f) = + (Arc::clone(&queue), Arc::clone(&calls), Arc::clone(&delays)); + let result = fetch_funding_tx_with_retry( + &canonical, + move |txid| { + let queue = Arc::clone(&queue_f); + let calls = Arc::clone(&calls_f); + async move { + calls.lock().expect("calls").push(txid); + queue + .lock() + .expect("script") + .pop_front() + .expect("script exhausted — the loop fetched more than scripted") + } + }, + move || { + let delays = Arc::clone(&delays_f); + async move { + delays.fetch_add(1, Ordering::SeqCst); + } + }, + ) + .await; + let recorded = calls.lock().expect("calls").clone(); + (result, recorded, delays.load(Ordering::SeqCst)) + } + + /// A first-call canonical hit returns immediately: one lookup, no + /// reversed fallback, no delay. + #[tokio::test] + async fn canonical_hit_returns_immediately() { + let (result, calls, delays) = run(vec![Ok(Some(7))]).await; + assert_eq!(result.expect("no error"), Some(7)); + assert_eq!(calls, vec![canonical_txid()]); + assert_eq!(delays, 0); + } + + /// A canonical miss falls back to the byte-reversed lookup WITHIN the + /// same attempt (the legacy-iOS little-endian compatibility path) — no + /// delay is spent on the byte-order fallback. + #[tokio::test] + async fn reversed_hit_within_same_attempt_no_delay() { + let (result, calls, delays) = run(vec![Ok(None), Ok(Some(9))]).await; + assert_eq!(result.expect("no error"), Some(9)); + assert_eq!( + calls, + vec![ + canonical_txid(), + reverse_txid_hex(&canonical_txid()).expect("reversible") + ] + ); + assert_eq!(delays, 0); + } + + /// A hit on a later attempt (propagation lag) succeeds after exactly + /// one delay per fully-missed attempt. + #[tokio::test] + async fn hit_after_missed_attempts_counts_one_delay_per_miss() { + // Attempts 1 and 2 miss on both orders; attempt 3 hits canonically. + let (result, calls, delays) = + run(vec![Ok(None), Ok(None), Ok(None), Ok(None), Ok(Some(3))]).await; + assert_eq!(result.expect("no error"), Some(3)); + assert_eq!(calls.len(), 5); + assert_eq!(delays, 2); + } + + /// Exhausting every attempt returns Ok(None) — a miss, not an error — + /// after 2 lookups per attempt, with NO delay after the final attempt. + #[tokio::test] + async fn exhausts_attempts_without_trailing_delay() { + let script = (0..CLAIM_FETCH_MAX_ATTEMPTS * 2) + .map(|_| Ok(None)) + .collect(); + let (result, calls, delays) = run(script).await; + assert_eq!(result.expect("misses are not errors"), None); + assert_eq!(calls.len(), (CLAIM_FETCH_MAX_ATTEMPTS * 2) as usize); + assert_eq!(delays, CLAIM_FETCH_MAX_ATTEMPTS - 1); + } + + /// A transport error is NOT masked as a miss: it propagates + /// immediately, with no further lookups and no extra delay. + #[tokio::test] + async fn transport_error_propagates_immediately() { + // Attempt 1 misses both orders (1 delay); attempt 2's canonical + // lookup errors. + let (result, calls, delays) = run(vec![ + Ok(None), + Ok(None), + Err(InvitationError::Invalid( + "simulated transport failure".to_string(), + )), + ]) + .await; + assert!( + matches!(result, Err(InvitationError::Invalid(_))), + "transport error must propagate, got {result:?}" + ); + assert_eq!(calls.len(), 3, "no lookup may follow the error"); + assert_eq!(delays, 1); + } + } + + // --- submit_claim_with_stale_islock_fallback: the claim submission seam --- + + mod claim_submission { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + use super::super::submit_claim_with_stale_islock_fallback; + use super::super::InvitationError; + use super::{asset_lock_tx_paying_voucher_at, voucher}; + use dpp::consensus::basic::identity::InvalidInstantAssetLockProofSignatureError; + use dpp::dashcore::InstantLock; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + + /// The one rejection the seam recovers from: Platform's stale-islock + /// consensus error, in the `Protocol(ConsensusError)` shape + /// `is_instant_lock_proof_invalid` matches. + fn stale_islock_rejection() -> crate::Error { + crate::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + InvalidInstantAssetLockProofSignatureError::new().into(), + ))) + } + + /// Any error the seam must NOT treat as a stale islock. + fn unrelated_sdk_error() -> crate::Error { + crate::Error::Config("simulated unrelated rejection".to_string()) + } + + /// An InstantSend primary — the variant is what the assertions key on, + /// so a scripted submission log reads as primary-vs-fallback directly. + fn instant_primary() -> AssetLockProof { + AssetLockProof::Instant(InstantAssetLockProof::new( + InstantLock::default(), + asset_lock_tx_paying_voucher_at(&voucher(), 0), + 0, + )) + } + + fn chain_fallback_proof() -> AssetLockProof { + AssetLockProof::Chain(ChainAssetLockProof::new(100, [0u8; 36])) + } + + /// Drive the seam with a scripted submit: each call records the proof + /// it was handed and pops the next scripted result. + async fn run( + chain_fallback: Option, + script: Vec>, + ) -> (Result, Vec) { + let queue: Arc>>> = + Arc::new(Mutex::new(script.into_iter().collect())); + let submitted: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (queue_f, submitted_f) = (Arc::clone(&queue), Arc::clone(&submitted)); + let result = submit_claim_with_stale_islock_fallback( + instant_primary(), + chain_fallback, + move |proof| { + let queue = Arc::clone(&queue_f); + let submitted = Arc::clone(&submitted_f); + async move { + submitted.lock().expect("submitted").push(proof); + queue + .lock() + .expect("script") + .pop_front() + .expect("script exhausted — the seam submitted more than scripted") + } + }, + ) + .await; + let recorded = submitted.lock().expect("submitted").clone(); + (result, recorded) + } + + /// A successful primary submission returns immediately: one submission, + /// and the ChainLock fallback is never touched even though it exists. + #[tokio::test] + async fn primary_success_never_submits_fallback() { + let (result, submitted) = run(Some(chain_fallback_proof()), vec![Ok(7)]).await; + assert_eq!(result.expect("primary success"), 7); + assert_eq!(submitted.len(), 1, "exactly one submission"); + assert!( + matches!(submitted[0], AssetLockProof::Instant(_)), + "the IS primary must be what was submitted" + ); + } + + /// A stale-islock rejection of the primary resubmits the ChainLock + /// fallback over the same outpoint, and the fallback's success is the + /// claim's success. + #[tokio::test] + async fn stale_islock_resubmits_chain_fallback() { + let (result, submitted) = run( + Some(chain_fallback_proof()), + vec![Err(stale_islock_rejection()), Ok(9)], + ) + .await; + assert_eq!(result.expect("fallback success"), 9); + assert_eq!(submitted.len(), 2, "primary then fallback"); + assert!( + matches!(submitted[0], AssetLockProof::Instant(_)), + "the IS primary must be tried first" + ); + assert!( + matches!(submitted[1], AssetLockProof::Chain(_)), + "the resubmission must carry the ChainLock fallback" + ); + } + + /// A stale-islock rejection with NO fallback (funding tx not yet + /// chain-locked) surfaces `InvitationError::NotChainLocked` — the clear retry + /// signal — after exactly one submission. + #[tokio::test] + async fn stale_islock_without_fallback_surfaces_retry_signal() { + let (result, submitted) = run(None, vec![Err(stale_islock_rejection())]).await; + assert!( + matches!(result, Err(InvitationError::NotChainLocked(_))), + "expected the not-chain-locked retry signal, got {result:?}" + ); + assert_eq!( + submitted.len(), + 1, + "no second submission is possible without a fallback" + ); + } + + /// An unrelated SDK error propagates unchanged after one submission: + /// the fallback exists but must NOT be tried for it. + #[tokio::test] + async fn unrelated_error_propagates_without_second_submission() { + let (result, submitted) = run( + Some(chain_fallback_proof()), + vec![Err(unrelated_sdk_error())], + ) + .await; + assert!( + matches!(result, Err(InvitationError::Sdk(_))), + "the unrelated error must propagate as Sdk, got {result:?}" + ); + assert_eq!( + submitted.len(), + 1, + "an unrelated error must not trigger the fallback" + ); + } + + /// The recovery is attempted exactly once: a fallback rejection — even + /// one wearing the stale-islock shape again — propagates rather than + /// looping. + #[tokio::test] + async fn fallback_rejection_is_not_recovered_again() { + let (result, submitted) = run( + Some(chain_fallback_proof()), + vec![Err(stale_islock_rejection()), Err(stale_islock_rejection())], + ) + .await; + assert!( + matches!(result, Err(InvitationError::Sdk(_))), + "the fallback's own rejection must propagate as Sdk, got {result:?}" + ); + assert_eq!(submitted.len(), 2, "no third submission may follow"); + } + } +} diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 9ca2704be31..3e3814e22bd 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -1,10 +1,11 @@ -//! DashPay contact request helpers +//! DashPay contact request and invitation helpers //! //! This module provides helper functions for creating and sending DashPay contact requests -//! according to DIP-15 specification. +//! according to DIP-15 specification, and the DashPay invitation link codec and claim. mod contact_request; mod contact_request_queries; +mod invitation; pub use contact_request::{ recipient_key_purpose_is_acceptable_on_receive, recipient_key_purpose_is_valid, @@ -12,6 +13,11 @@ pub use contact_request::{ EcdhProvider, RecipientIdentity, SendContactRequestInput, SendContactRequestResult, }; pub use contact_request_queries::ContactRequestDocuments; +pub use invitation::{ + encode_invitation_uri, parse_invitation_uri, voucher_credit_output, voucher_output_index, + wif_network_matches, InvitationClaimStatus, InvitationError, InviterInfo, ParsedInvitation, + MAX_INVITATION_DUFFS, MAX_INVITATION_TTL_SECS, MIN_INVITATION_DUFFS, +}; use crate::{Error, Sdk}; use dpp::prelude::Identifier; From 21118f45feb7326082f66173eb2378642a34afa3 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 11:20:01 -0500 Subject: [PATCH 08/20] feat(sdk): forward masternode voting on DPNS contests in dash-sdk-cxx Adds the calls the Dash Core masternode-voting feature needs, each a forward to an existing dash-sdk API: - current_contests() -> Sdk::get_current_dpns_contests - identity_votes(pro_tx_hash) -> ResourceVote::fetch_many with the SDK's ProTxHash query. Sdk::get_contested_dpns_identity_votes is not used: it is a stub that always returns no votes. - cast_masternode_vote(...) -> PutVote::put_to_platform_and_wait_for_response with voter_identity_voting_key, and the SDK's voting-key diagnosis (is_voting_key_failure / diagnose_voting_key_failure) appended to a refused key's message. The embedder passes proTxHashes in its internal uint256 order; they are reversed into the SDK's ProTxHash. The vote poll for a label is built the way the SDK's DPNS contest queries build it, as no SDK helper builds a DPNS vote yet. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk-cxx/include/dash/sdk/callbacks.h | 4 +- packages/rs-sdk-cxx/src/client.rs | 81 ++++++++++- packages/rs-sdk-cxx/src/lib.rs | 48 +++++++ packages/rs-sdk-cxx/src/marshal.rs | 136 +++++++++++++++++- packages/rs-sdk-cxx/tests/cxx_smoke.cc | 15 ++ 5 files changed, 277 insertions(+), 7 deletions(-) diff --git a/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h b/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h index ca4cef5f820..59ed74ad29d 100644 --- a/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h +++ b/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h @@ -46,7 +46,9 @@ bool Guard(Fn&& body) noexcept //! 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 +//! ECDSA_HASH160 = 2). ECDSA_HASH160 is a masternode voting key +//! (cast_masternode_vote): key_id is 0, the id on the voter identity, and +//! key_data is the voting key's HASH160. 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 diff --git a/packages/rs-sdk-cxx/src/client.rs b/packages/rs-sdk-cxx/src/client.rs index 8666a556a1a..54499a4d6e9 100644 --- a/packages/rs-sdk-cxx/src/client.rs +++ b/packages/rs-sdk-cxx/src/client.rs @@ -14,6 +14,7 @@ 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::hashes::Hash; use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, SecretKey}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::document::DocumentV0Getters; @@ -23,15 +24,20 @@ 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::voting::votes::resource_vote::ResourceVote; 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::masternode_vote_keys::{ + diagnose_voting_key_failure, is_voting_key_failure, voter_identity_voting_key, +}; use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; +use dash_sdk::platform::transition::vote::PutVote; use dash_sdk::platform::types::identity::PublicKeyHash; -use dash_sdk::platform::Fetch; +use dash_sdk::platform::{Fetch, FetchMany}; use dash_sdk::sdk::{Address, AddressList}; use dash_sdk::{Error, Sdk, SdkBuilder}; @@ -147,6 +153,7 @@ result_from!(VoteStateResult { }); result_from!(ContestsResult { contests: Vec = Vec::new() }); result_from!(ContactRequestsResult { requests: Vec = Vec::new() }); +result_from!(NameVotesResult { votes: Vec = Vec::new() }); impl From, bool), Status>> for ffi::NamesResult { fn from(outcome: Result<(Vec, bool), Status>) -> Self { @@ -350,6 +357,36 @@ impl PlatformClient { .into() } + pub fn current_contests(&self) -> ffi::ContestsResult { + self.call("current_contests", |sdk| async move { + let contests = sdk.get_current_dpns_contests(None, None, None).await?; + Ok(contests + .into_iter() + .map(|(name, ends_at_ms)| ffi::Contest { + name, + ends_at_ms, + state: marshal::empty_vote_state(), + }) + .collect()) + }) + .into() + } + + pub fn identity_votes(&self, pro_tx_hash: &[u8; 32]) -> ffi::NameVotesResult { + self.call("identity_votes", |sdk| { + let pro_tx_hash = marshal::pro_tx_hash(pro_tx_hash); + async move { + let votes = ResourceVote::fetch_many(&sdk, pro_tx_hash).await?; + Ok(votes + .values() + .flatten() + .filter_map(marshal::name_vote) + .collect()) + } + }) + .into() + } + pub fn contact_requests( &self, identity: &[u8; 32], @@ -549,6 +586,48 @@ impl PlatformClient { }) .into() } + + pub fn cast_masternode_vote( + &self, + pro_tx_hash: &[u8; 32], + voting_key_hash: &[u8; 20], + label: &str, + choice: ffi::VoteChoice, + towards: &[u8; 32], + signer: cxx::UniquePtr, + ) -> Status { + self.call("cast_masternode_vote", |sdk| { + let pro_tx_hash = marshal::pro_tx_hash(pro_tx_hash); + let voting_key_hash = *voting_key_hash; + let vote = marshal::dpns_vote(label, choice, towards); + async move { + let signer = WalletSigner::new(signer)?; + if let Err(error) = vote? + .put_to_platform_and_wait_for_response( + Identifier::from(pro_tx_hash.to_byte_array()), + &voter_identity_voting_key(&voting_key_hash), + &sdk, + &signer, + None, + ) + .await + { + let mut status = classify(&error); + if is_voting_key_failure(&error) { + if let Some(problem) = + diagnose_voting_key_failure(&sdk, pro_tx_hash, &voting_key_hash).await + { + status.message = format!("{}: {problem:?}", status.message); + } + } + return Err(status); + } + Ok(()) + } + }) + .err() + .unwrap_or_else(Status::ok) + } } impl Drop for PlatformClient { diff --git a/packages/rs-sdk-cxx/src/lib.rs b/packages/rs-sdk-cxx/src/lib.rs index 69529d9f184..0c9fab24f17 100644 --- a/packages/rs-sdk-cxx/src/lib.rs +++ b/packages/rs-sdk-cxx/src/lib.rs @@ -178,6 +178,7 @@ pub mod ffi { } /// A contested DPNS name: its normalized label, end time and state. + /// `state` is left empty by `current_contests`. #[derive(Debug, Clone)] struct Contest { name: String, @@ -191,6 +192,29 @@ pub mod ffi { contests: Vec, } + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + enum VoteChoice { + TowardsIdentity = 0, + Abstain = 1, + Lock = 2, + } + + /// A masternode's vote on a DPNS name; `towards` is all zero unless + /// `choice` is `TowardsIdentity`. + #[derive(Debug, Clone)] + struct NameVote { + normalized_label: String, + choice: VoteChoice, + towards: [u8; 32], + } + + #[derive(Debug, Clone)] + struct NameVotesResult { + status: Status, + votes: Vec, + } + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] enum ContactDirection { @@ -356,6 +380,14 @@ pub mod ffi { ) -> ContestsResult; /// The contest for a DPNS label (normalized here). fn contested_vote_state(self: &PlatformClient, label: &str) -> VoteStateResult; + /// DPNS names whose contest is open, with their end times. Best + /// effort: the SDK may stop after its first page of 100 polls. + fn current_contests(self: &PlatformClient) -> ContestsResult; + /// One proved page (up to 100) of the DPNS votes the masternode + /// `pro_tx_hash` has cast, possibly not all: the SDK cannot ask for + /// the next page. Platform records votes by proTxHash, not by voter + /// identity. Hash in the embedder's internal byte order. + fn identity_votes(self: &PlatformClient, pro_tx_hash: &[u8; 32]) -> NameVotesResult; /// Every request sent by or to `identity`, created after `since_ms` /// (0 = all). fn contact_requests( @@ -401,6 +433,22 @@ pub mod ffi { signer: UniquePtr, keys: UniquePtr, ) -> IdResult; + /// Votes on the contest for `label` (normalized here) as the + /// masternode `pro_tx_hash` (internal byte order), signing with its + /// voting key, whose HASH160 (CKeyID bytes, not reversed) is + /// `voting_key_hash`. `towards` is read only for `TowardsIdentity`. + /// A refused voting key gets the SDK's diagnosis appended to the + /// message. `Ok` when the SDK reports the vote stored; on a duplicate + /// broadcast it reports whatever vote Platform holds for the poll. + fn cast_masternode_vote( + self: &PlatformClient, + pro_tx_hash: &[u8; 32], + voting_key_hash: &[u8; 20], + label: &str, + choice: VoteChoice, + towards: &[u8; 32], + signer: UniquePtr, + ) -> Status; // No network. A panic answers the safe value: not valid, contested. fn is_valid_username(label: &str) -> bool; diff --git a/packages/rs-sdk-cxx/src/marshal.rs b/packages/rs-sdk-cxx/src/marshal.rs index 810d14d8d6b..f5ac3c71b88 100644 --- a/packages/rs-sdk-cxx/src/marshal.rs +++ b/packages/rs-sdk-cxx/src/marshal.rs @@ -7,7 +7,8 @@ use std::collections::BTreeMap; use dash_sdk::dpp::dashcore::consensus::Decodable; -use dash_sdk::dpp::dashcore::{InstantLock, OutPoint, Transaction}; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::{InstantLock, OutPoint, ProTxHash, 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; @@ -16,21 +17,35 @@ use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAss 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::prelude::{AssetLockProof, Identifier}; +use dash_sdk::dpp::system_data_contracts::SystemDataContract; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; 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::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use dash_sdk::dpp::voting::vote_polls::VotePoll; +use dash_sdk::dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; +use dash_sdk::dpp::voting::votes::resource_vote::v0::ResourceVoteV0; +use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; +use dash_sdk::dpp::voting::votes::Vote; +use dash_sdk::platform::dpns_usernames::{convert_to_homograph_safe_chars, 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. +/// A 32-byte chain hash (quorum hash, proTxHash) 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 } +/// A proTxHash in the embedder's internal order, as the typed `ProTxHash` +/// (Core's RPC hex order) the SDK's voting calls take. +pub fn pro_tx_hash(internal_order: &[u8; 32]) -> ProTxHash { + ProTxHash::from_byte_array(reversed(internal_order)) +} + pub fn identity(identity: &Identity) -> ffi::Identity { ffi::Identity { id: identity.id().to_buffer(), @@ -139,6 +154,58 @@ pub fn empty_vote_state() -> ffi::VoteState { } } +/// The DPNS poll a vote on `label` goes to, as the SDK's DPNS contest +/// queries build it, with the label normalized the same way. +fn dpns_vote_poll(label: &str) -> ContestedDocumentResourceVotePoll { + ContestedDocumentResourceVotePoll { + contract_id: SystemDataContract::DPNS.id(), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text(convert_to_homograph_safe_chars(label)), + ], + } +} + +pub fn dpns_vote(label: &str, choice: ffi::VoteChoice, towards: &[u8; 32]) -> Result { + let resource_vote_choice = match choice { + ffi::VoteChoice::TowardsIdentity => { + ResourceVoteChoice::TowardsIdentity(Identifier::from(*towards)) + } + ffi::VoteChoice::Abstain => ResourceVoteChoice::Abstain, + ffi::VoteChoice::Lock => ResourceVoteChoice::Lock, + other => return Err(format!("unknown vote choice {}", other.repr)), + }; + Ok(Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::ContestedDocumentResourceVotePoll(dpns_vote_poll(label)), + resource_vote_choice, + }))) +} + +/// A DPNS vote, `None` for a vote on any other poll. +pub fn name_vote(vote: &ResourceVote) -> Option { + let VotePoll::ContestedDocumentResourceVotePoll(poll) = vote.vote_poll(); + if poll.contract_id != SystemDataContract::DPNS.id() || poll.document_type_name != "domain" { + return None; + } + let Some(Value::Text(label)) = poll.index_values.get(1) else { + return None; + }; + let (choice, towards) = match vote.resource_vote_choice() { + ResourceVoteChoice::TowardsIdentity(id) => { + (ffi::VoteChoice::TowardsIdentity, id.to_buffer()) + } + ResourceVoteChoice::Abstain => (ffi::VoteChoice::Abstain, [0; 32]), + ResourceVoteChoice::Lock => (ffi::VoteChoice::Lock, [0; 32]), + }; + Some(ffi::NameVote { + normalized_label: label.clone(), + choice, + towards, + }) +} + pub fn contact_request(document: &Document) -> Result { let properties: &BTreeMap = document.properties(); let field = |name: &str| { @@ -229,4 +296,63 @@ mod tests { properties.remove("toUserId"); assert!(contact_request(&document(properties)).is_err()); } + + /// Core hands over a proTxHash in its internal `uint256` order; the + /// SDK's `ProTxHash` reads as Core's RPC hex. + #[test] + fn a_core_pro_tx_hash_becomes_the_rpc_order_pro_tx_hash() { + use std::str::FromStr; + + let rpc_hex = "74e26f433328be4b833b8958c04c51615e03853378e0d56fbe5ecf24977f884b"; + let mut core_internal: [u8; 32] = + std::array::from_fn(|i| u8::from_str_radix(&rpc_hex[2 * i..2 * i + 2], 16).unwrap()); + core_internal.reverse(); + assert_eq!( + pro_tx_hash(&core_internal), + ProTxHash::from_str(rpc_hex).unwrap() + ); + } + + /// A vote built for a label reads back as the same DPNS name vote; a + /// vote on another contract's poll is not a name vote. + #[test] + fn a_dpns_vote_round_trips_and_other_polls_are_skipped() { + let Vote::ResourceVote(vote) = + dpns_vote("Alice", ffi::VoteChoice::TowardsIdentity, &[4; 32]).expect("builds"); + let marshalled = name_vote(&vote).expect("a DPNS vote"); + assert_eq!(marshalled.normalized_label, "a11ce"); + assert_eq!(marshalled.choice, ffi::VoteChoice::TowardsIdentity); + assert_eq!(marshalled.towards, [4; 32]); + + // The poll names DPNS's contested index, its values in the index's + // property order. + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dash_sdk::dpp::system_data_contracts::load_system_data_contract; + use dash_sdk::dpp::version::PlatformVersion; + let mut poll = dpns_vote_poll("alice"); + let dpns = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()).unwrap(); + let domain = dpns + .document_type_for_name(&poll.document_type_name) + .unwrap(); + let index = &domain.indexes()[&poll.index_name]; + assert!(index.contested_index.is_some()); + assert_eq!( + index + .properties + .iter() + .map(|p| p.name.as_str()) + .collect::>(), + ["normalizedParentDomainName", "normalizedLabel"] + ); + + poll.contract_id = [9; 32].into(); + let other = ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::ContestedDocumentResourceVotePoll(poll), + resource_vote_choice: ResourceVoteChoice::Lock, + }); + assert!(name_vote(&other).is_none()); + assert!(dpns_vote("alice", ffi::VoteChoice { repr: 9 }, &[0; 32]).is_err()); + } } diff --git a/packages/rs-sdk-cxx/tests/cxx_smoke.cc b/packages/rs-sdk-cxx/tests/cxx_smoke.cc index 859b943fba2..eca45242cd8 100644 --- a/packages/rs-sdk-cxx/tests/cxx_smoke.cc +++ b/packages/rs-sdk-cxx/tests/cxx_smoke.cc @@ -40,6 +40,12 @@ bool unproved(const Status& status) return status.kind != StatusKind::Ok && status.kind != StatusKind::NotFound; } +//! The input converted and the call reached the closed port. +bool unreachable(const Status& status) +{ + return status.kind == StatusKind::Unavailable; +} + //! Flags its destruction, so a test can see when the CoreContext holding it //! is destroyed. struct Sentinel { @@ -83,6 +89,7 @@ int main() static_assert(static_cast(StatusKind::NotFound) == 1); static_assert(static_cast(StatusKind::Consensus) == 4); static_assert(static_cast(AssetLockProofKind::Chain) == 1); + static_assert(sizeof(VoteChoice) == 1 && static_cast(VoteChoice::Lock) == 2); // Configs the client cannot honour are errors, never a direct fallback. { @@ -138,6 +145,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(unreachable(client->current_contests().status), "current_contests"); + check(unreachable(client->identity_votes(id).status), "identity_votes"); 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"); @@ -199,6 +208,12 @@ int main() nullptr); check(unproved(client->send_contact_request(request, signer(false), std::move(contact_keys)).status), "send_contact_request"); + const std::array voting_key{3}; + check(unreachable(client->cast_masternode_vote(id, voting_key, "alice", VoteChoice::Abstain, id, signer(false))), + "cast_masternode_vote"); + check(client->cast_masternode_vote(id, voting_key, "alice", static_cast(9), id, signer(false)).kind == + StatusKind::Internal, + "unknown vote choice accepted"); // No network. check(is_valid_username("alice") && !is_valid_username("-"), "is_valid_username"); From 7fff2dc85afc8359e95f0f306e837804d291db9c Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 08:36:11 -0500 Subject: [PATCH 09/20] feat(sdk): forward DashPay profiles in dash-sdk-cxx Adds get_profiles, create_profile and update_profile, forwards to the SDK's Sdk::get_profiles, Sdk::create_profile and Sdk::update_profile. The DashPay contract comes from Sdk::fetch_system_data_contract, which the binding's context provider answers locally, and the signing key is read from the owner's identity by id, as register_dpns_name does. Create and update stay separate calls, as in the SDK: the embedder already knows from get_profiles whether a profile exists. The avatar hash and fingerprint are computed by the embedder; an empty field is left alone or omitted, and a hash or fingerprint of the wrong length is refused before any network call. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk-cxx/src/client.rs | 79 ++++++++++++++++++ packages/rs-sdk-cxx/src/lib.rs | 62 ++++++++++++++ packages/rs-sdk-cxx/src/marshal.rs | 108 +++++++++++++++++++++++-- packages/rs-sdk-cxx/tests/cxx_smoke.cc | 9 +++ 4 files changed, 250 insertions(+), 8 deletions(-) diff --git a/packages/rs-sdk-cxx/src/client.rs b/packages/rs-sdk-cxx/src/client.rs index 54499a4d6e9..09784d12913 100644 --- a/packages/rs-sdk-cxx/src/client.rs +++ b/packages/rs-sdk-cxx/src/client.rs @@ -17,6 +17,7 @@ use dash_sdk::dpp::consensus::codes::ErrorWithCode; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, SecretKey}; use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::data_contract::DataContract; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -24,6 +25,7 @@ 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::system_data_contracts::SystemDataContract; use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; use dash_sdk::dpp::ProtocolError; use dash_sdk::platform::dashpay::{ @@ -154,6 +156,10 @@ result_from!(VoteStateResult { result_from!(ContestsResult { contests: Vec = Vec::new() }); result_from!(ContactRequestsResult { requests: Vec = Vec::new() }); result_from!(NameVotesResult { votes: Vec = Vec::new() }); +result_from!(ProfileResult { + profile: ffi::Profile = Default::default() +}); +result_from!(ProfilesResult { profiles: Vec = Vec::new() }); impl From, bool), Status>> for ffi::NamesResult { fn from(outcome: Result<(Vec, bool), Status>) -> Self { @@ -422,6 +428,22 @@ impl PlatformClient { .into() } + pub fn get_profiles(&self, owners: &[[u8; 32]]) -> ffi::ProfilesResult { + self.call("get_profiles", |sdk| { + let owners: Vec = owners.iter().copied().map(Identifier::from).collect(); + async move { + let contract = dashpay_contract(&sdk).await?; + sdk.get_profiles(&contract, &owners) + .await? + .values() + .map(marshal::profile) + .collect::>() + .map_err(|e| Status::new(StatusKind::Rejected, e)) + } + }) + .into() + } + pub fn register_identity( &self, keys: &[ffi::IdentityKey], @@ -587,6 +609,56 @@ impl PlatformClient { .into() } + pub fn create_profile( + &self, + owner: &[u8; 32], + key_id: u32, + fields: &ffi::ProfileFields, + signer: cxx::UniquePtr, + ) -> ffi::ProfileResult { + self.put_profile("create_profile", false, owner, key_id, fields, signer) + } + + pub fn update_profile( + &self, + owner: &[u8; 32], + key_id: u32, + fields: &ffi::ProfileFields, + signer: cxx::UniquePtr, + ) -> ffi::ProfileResult { + self.put_profile("update_profile", true, owner, key_id, fields, signer) + } + + fn put_profile( + &self, + what: &str, + update: bool, + owner: &[u8; 32], + key_id: u32, + fields: &ffi::ProfileFields, + signer: cxx::UniquePtr, + ) -> ffi::ProfileResult { + self.call(what, |sdk| { + let owner = Identifier::from(*owner); + let fields = marshal::profile_fields(fields); + async move { + let signer = WalletSigner::new(signer)?; + let fields = fields?; + let contract = dashpay_contract(&sdk).await?; + let (_, key) = identity_and_key(&sdk, owner, key_id).await?; + let document = if update { + sdk.update_profile(&contract, owner, fields, &key, &signer) + .await? + } else { + sdk.create_profile(&contract, owner, fields, &key, &signer) + .await? + }; + marshal::profile(&document).map_err(|e| Status::new(StatusKind::Rejected, e)) + } + }) + .into() + } + pub fn cast_masternode_vote( &self, pro_tx_hash: &[u8; 32], @@ -672,6 +744,13 @@ async fn identity_and_key( Ok((identity, key)) } +/// The DashPay contract, which the context provider serves locally. +async fn dashpay_contract(sdk: &Sdk) -> Result, Status> { + sdk.fetch_system_data_contract(SystemDataContract::Dashpay.id()) + .await? + .ok_or_else(|| Status::internal("no DashPay contract")) +} + /// 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> { diff --git a/packages/rs-sdk-cxx/src/lib.rs b/packages/rs-sdk-cxx/src/lib.rs index 0c9fab24f17..c2b1a0ebdaf 100644 --- a/packages/rs-sdk-cxx/src/lib.rs +++ b/packages/rs-sdk-cxx/src/lib.rs @@ -279,6 +279,46 @@ pub mod ffi { account_label: String, } + /// DashPay profile fields to set: an empty one is left as it is by + /// `update_profile` and omitted by `create_profile`. The embedder + /// computes `avatar_hash` (SHA-256 of the image, 32 bytes) and + /// `avatar_fingerprint` (its dHash, 8 bytes) and sets them with a new + /// `avatar_url`. + #[derive(Debug, Clone)] + struct ProfileFields { + display_name: String, + public_message: String, + avatar_url: String, + avatar_hash: Vec, + avatar_fingerprint: Vec, + } + + /// A DashPay `profile` document; an empty field is absent. + #[derive(Debug, Clone, Default)] + struct Profile { + document_id: [u8; 32], + owner: [u8; 32], + revision: u64, + display_name: String, + public_message: String, + avatar_url: String, + avatar_hash: Vec, + avatar_fingerprint: Vec, + updated_at_ms: u64, + } + + #[derive(Debug, Clone)] + struct ProfileResult { + status: Status, + profile: Profile, + } + + #[derive(Debug, Clone)] + struct ProfilesResult { + status: Status, + profiles: Vec, + } + /// The DIP-15 compact extended public key, unvalidated beyond its /// length: the embedder checks `public_key` is a valid point. #[derive(Debug, Clone)] @@ -388,6 +428,9 @@ pub mod ffi { /// the next page. Platform records votes by proTxHash, not by voter /// identity. Hash in the embedder's internal byte order. fn identity_votes(self: &PlatformClient, pro_tx_hash: &[u8; 32]) -> NameVotesResult; + /// The DashPay profiles of `owners` (unique, at most 100); an owner + /// without one is missing from the result. + fn get_profiles(self: &PlatformClient, owners: &[[u8; 32]]) -> ProfilesResult; /// Every request sent by or to `identity`, created after `since_ms` /// (0 = all). fn contact_requests( @@ -433,6 +476,25 @@ pub mod ffi { signer: UniquePtr, keys: UniquePtr, ) -> IdResult; + /// Creates `owner`'s DashPay profile, signed with its key `key_id` + /// (a HIGH or CRITICAL authentication key); the profile as Platform + /// confirmed it. + fn create_profile( + self: &PlatformClient, + owner: &[u8; 32], + key_id: u32, + fields: &ProfileFields, + signer: UniquePtr, + ) -> ProfileResult; + /// Sets `fields` on `owner`'s existing profile, keeping the others, + /// as `create_profile` signs; fails if it has none. + fn update_profile( + self: &PlatformClient, + owner: &[u8; 32], + key_id: u32, + fields: &ProfileFields, + signer: UniquePtr, + ) -> ProfileResult; /// Votes on the contest for `label` (normalized here) as the /// masternode `pro_tx_hash` (internal byte order), signing with its /// voting key, whose HASH160 (CKeyID bytes, not reversed) is diff --git a/packages/rs-sdk-cxx/src/marshal.rs b/packages/rs-sdk-cxx/src/marshal.rs index f5ac3c71b88..c42526d4e60 100644 --- a/packages/rs-sdk-cxx/src/marshal.rs +++ b/packages/rs-sdk-cxx/src/marshal.rs @@ -27,6 +27,7 @@ use dash_sdk::dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGett use dash_sdk::dpp::voting::votes::resource_vote::v0::ResourceVoteV0; use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; use dash_sdk::dpp::voting::votes::Vote; +use dash_sdk::platform::dashpay::ProfileFields; use dash_sdk::platform::dpns_usernames::{convert_to_homograph_safe_chars, DpnsUsername}; use dash_sdk::query_types::Contenders; @@ -206,6 +207,64 @@ pub fn name_vote(vote: &ResourceVote) -> Option { }) } +/// A document's optional property, `T::default()` when absent. +fn optional_property( + document: &Document, + kind: &str, + name: &str, + read: impl FnOnce(&Value) -> Result, +) -> Result { + document + .properties() + .get(name) + .map(read) + .transpose() + .map(Option::unwrap_or_default) + .map_err(|e| format!("{kind} {name}: {e}")) +} + +/// An embedder string where empty means absent. +fn non_empty(value: &str) -> Option { + (!value.is_empty()).then(|| value.to_owned()) +} + +/// An optional fixed-size field: empty is `None`, any other length an error. +fn optional_array(bytes: &[u8], name: &str) -> Result, String> { + if bytes.is_empty() { + return Ok(None); + } + bytes + .try_into() + .map(Some) + .map_err(|_| format!("{name} must be {N} bytes, not {}", bytes.len())) +} + +pub fn profile_fields(fields: &ffi::ProfileFields) -> Result { + Ok(ProfileFields { + display_name: non_empty(&fields.display_name), + public_message: non_empty(&fields.public_message), + avatar_url: non_empty(&fields.avatar_url), + avatar_hash: optional_array(&fields.avatar_hash, "avatar_hash")?, + avatar_fingerprint: optional_array(&fields.avatar_fingerprint, "avatar_fingerprint")?, + }) +} + +pub fn profile(document: &Document) -> Result { + let text = |name| optional_property(document, "profile", name, Value::to_text); + let bytes = |name| optional_property(document, "profile", name, Value::to_binary_bytes); + Ok(ffi::Profile { + document_id: document.id().to_buffer(), + owner: document.owner_id().to_buffer(), + revision: document.revision().unwrap_or(0), + display_name: text("displayName")?, + public_message: text("publicMessage")?, + avatar_url: text("avatarUrl")?, + avatar_hash: bytes("avatarHash")?, + avatar_fingerprint: bytes("avatarFingerprint")?, + updated_at_ms: document.updated_at().unwrap_or(0), + }) +} + pub fn contact_request(document: &Document) -> Result { let properties: &BTreeMap = document.properties(); let field = |name: &str| { @@ -218,14 +277,8 @@ pub fn contact_request(document: &Document) -> Result() .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}")) - }; + let optional_bytes = + |name| optional_property(document, "contactRequest", name, Value::to_binary_bytes); Ok(ffi::ContactRequest { document_id: document.id().to_buffer(), owner: document.owner_id().to_buffer(), @@ -297,6 +350,45 @@ mod tests { assert!(contact_request(&document(properties)).is_err()); } + #[test] + fn a_profile_document_is_marshalled_and_its_fields_convert() { + let document: Document = DocumentV0 { + id: [1; 32].into(), + owner_id: [2; 32].into(), + properties: BTreeMap::from([ + ("displayName".to_string(), Value::Text("Alice".into())), + ("avatarHash".to_string(), Value::Bytes32([3; 32])), + ("avatarFingerprint".to_string(), Value::Bytes(vec![4; 8])), + ]), + revision: Some(5), + updated_at: Some(6), + ..Default::default() + } + .into(); + let profile = profile(&document).expect("marshals"); + assert_eq!((profile.document_id, profile.owner), ([1; 32], [2; 32])); + assert_eq!((profile.revision, profile.updated_at_ms), (5, 6)); + assert_eq!(profile.display_name, "Alice"); + assert!(profile.public_message.is_empty() && profile.avatar_url.is_empty()); + assert_eq!(profile.avatar_hash, vec![3; 32]); + assert_eq!(profile.avatar_fingerprint, vec![4; 8]); + + let mut fields = ffi::ProfileFields { + display_name: "Alice".into(), + public_message: String::new(), + avatar_url: "https://a.example/a.png".into(), + avatar_hash: vec![3; 32], + avatar_fingerprint: Vec::new(), + }; + let converted = profile_fields(&fields).expect("converts"); + assert_eq!(converted.display_name.as_deref(), Some("Alice")); + assert_eq!(converted.public_message, None); + assert_eq!(converted.avatar_hash, Some([3; 32])); + assert_eq!(converted.avatar_fingerprint, None); + fields.avatar_fingerprint = vec![4; 7]; + assert!(profile_fields(&fields).is_err()); + } + /// Core hands over a proTxHash in its internal `uint256` order; the /// SDK's `ProTxHash` reads as Core's RPC hex. #[test] diff --git a/packages/rs-sdk-cxx/tests/cxx_smoke.cc b/packages/rs-sdk-cxx/tests/cxx_smoke.cc index eca45242cd8..72fb8a23105 100644 --- a/packages/rs-sdk-cxx/tests/cxx_smoke.cc +++ b/packages/rs-sdk-cxx/tests/cxx_smoke.cc @@ -148,6 +148,8 @@ int main() check(unreachable(client->current_contests().status), "current_contests"); check(unreachable(client->identity_votes(id).status), "identity_votes"); check(unproved(client->contact_requests(id, ContactDirection::Received, 0).status), "contact_requests"); + const std::vector> owners{id}; + check(unreachable(client->get_profiles({owners.data(), owners.size()}).status), "get_profiles"); check(client->contact_requests(id, static_cast(9), 0).status.kind == StatusKind::Internal, "unknown contact direction accepted"); @@ -208,6 +210,13 @@ int main() nullptr); check(unproved(client->send_contact_request(request, signer(false), std::move(contact_keys)).status), "send_contact_request"); + ProfileFields fields{}; + fields.display_name = "Alice"; + check(unreachable(client->create_profile(id, 1, fields, signer(false)).status), "create_profile"); + check(unreachable(client->update_profile(id, 1, fields, signer(false)).status), "update_profile"); + fields.avatar_hash.push_back(0x01); + check(client->update_profile(id, 1, fields, signer(false)).status.kind == StatusKind::Internal, + "a 1-byte avatar hash accepted"); const std::array voting_key{3}; check(unreachable(client->cast_masternode_vote(id, voting_key, "alice", VoteChoice::Abstain, id, signer(false))), "cast_masternode_vote"); From a4bb9f02e9a203e8c217e45363138522dd106ead Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 16 Oct 2026 08:38:37 -0500 Subject: [PATCH 10/20] feat(sdk): forward DashPay invitations in dash-sdk-cxx Adds the invitation link codec and claim, forwards to the SDK's dashpay invitation module: - encode_invitation -> encode_invitation_uri - parse_invitation -> parse_invitation_uri, returning an opaque Invitation handle; details() reads the link's inviter and funding fields, never the voucher key - invitation_claim_status -> Sdk::invitation_claim_status - claim_invitation -> Sdk::claim_invitation, with the WalletSigner signing the new identity's keys and the voucher key signing the asset lock in Rust The voucher key, a bearer secret, stays inside the Invitation handle and is wiped when it, or the per-call copy the SDK consumes, is dropped. A refused link or funding transaction reads as Rejected and a ChainLock still to come as Unavailable; SDK errors keep the existing table. The new identity's keys are converted by the helper register_identity now shares. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk-cxx/src/client.rs | 103 ++++++++++++++---- packages/rs-sdk-cxx/src/lib.rs | 142 +++++++++++++++++++++++++ packages/rs-sdk-cxx/src/marshal.rs | 55 +++++++++- packages/rs-sdk-cxx/tests/cxx_smoke.cc | 39 +++++++ 4 files changed, 317 insertions(+), 22 deletions(-) diff --git a/packages/rs-sdk-cxx/src/client.rs b/packages/rs-sdk-cxx/src/client.rs index 09784d12913..38a2a931ce7 100644 --- a/packages/rs-sdk-cxx/src/client.rs +++ b/packages/rs-sdk-cxx/src/client.rs @@ -16,11 +16,9 @@ use dash_sdk::dapi_client::transport::{ProxyEndpoint, Socks5Auth, Socks5Proxy}; use dash_sdk::dpp::consensus::codes::ErrorWithCode; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, SecretKey}; -use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::DataContract; 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; @@ -29,7 +27,7 @@ use dash_sdk::dpp::system_data_contracts::SystemDataContract; use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; use dash_sdk::dpp::ProtocolError; use dash_sdk::platform::dashpay::{ - ContactRequestInput, EcdhProvider, RecipientIdentity, SendContactRequestInput, + ContactRequestInput, EcdhProvider, InvitationError, RecipientIdentity, SendContactRequestInput, }; use dash_sdk::platform::dpns_usernames::{convert_to_homograph_safe_chars, RegisterDpnsNameInput}; use dash_sdk::platform::transition::masternode_vote_keys::{ @@ -47,7 +45,7 @@ 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}; +use crate::{guarded, marshal, Invitation}; pub struct PlatformClient { runtime: Runtime, @@ -91,6 +89,21 @@ impl From for Status { } } +/// ... and an invitation error: a refused link or funding transaction is +/// `Rejected`, a ChainLock still to come `Unavailable`. +impl From for Status { + fn from(error: InvitationError) -> Self { + let kind = match &error { + InvitationError::Sdk(error) => return classify(error), + InvitationError::NotChainLocked(_) => StatusKind::Unavailable, + InvitationError::Invalid(_) | InvitationError::NetworkMismatch { .. } => { + StatusKind::Rejected + } + }; + Status::new(kind, error.to_string()) + } +} + /// 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 { @@ -160,6 +173,9 @@ result_from!(ProfileResult { profile: ffi::Profile = Default::default() }); result_from!(ProfilesResult { profiles: Vec = Vec::new() }); +result_from!(InvitationClaimResult { + claim: ffi::InvitationClaim = Default::default() +}); impl From, bool), Status>> for ffi::NamesResult { fn from(outcome: Result<(Vec, bool), Status>) -> Self { @@ -180,13 +196,7 @@ impl PlatformClient { 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 network = marshal::network(config.network)?; let proxy = proxy(&config.proxy)?; let addresses = config .endpoints @@ -452,15 +462,7 @@ impl PlatformClient { 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 public_keys = marshal::new_identity_keys(keys); let proof = marshal::asset_lock_proof(proof); async move { // The id is derived from the asset lock when the transition is built. @@ -609,6 +611,43 @@ impl PlatformClient { .into() } + pub fn invitation_claim_status(&self, invitation: &Invitation) -> ffi::InvitationClaimResult { + self.call("invitation_claim_status", |sdk| { + let invitation = invitation.copy(); + async move { + let claim = sdk.invitation_claim_status(&invitation).await?; + Ok(ffi::InvitationClaim { + prospective_identity: claim.prospective_identity_id.to_buffer(), + amount_duffs: claim.amount_duffs, + instant: claim.is_instant, + chain_locked: claim.is_chain_locked, + already_claimed: claim.already_claimed, + }) + } + }) + .into() + } + + pub fn claim_invitation( + &self, + invitation: &Invitation, + keys: &[ffi::IdentityKey], + signer: cxx::UniquePtr, + ) -> ffi::IdentityResult { + self.call("claim_invitation", |sdk| { + let invitation = invitation.copy(); + let keys = marshal::new_identity_keys(keys); + async move { + let signer = WalletSigner::new(signer)?; + let identity = sdk + .claim_invitation(invitation, keys?, &signer, None) + .await?; + Ok(marshal::identity(&identity)) + } + }) + .into() + } + pub fn create_profile( &self, owner: &[u8; 32], @@ -806,6 +845,7 @@ mod tests { use super::*; use dash_sdk::dapi_client::DapiClientError; use dash_sdk::dpp::consensus::ConsensusError; + use dash_sdk::dpp::dashcore::Network; use dash_sdk::error::{ContextProviderError, StateTransitionBroadcastError}; /// Only a proof yields `Ok` or `NotFound`; consensus refusals keep @@ -856,6 +896,29 @@ mod tests { ] { assert_eq!(classify(&error).kind, kind, "{error}"); } + for (error, kind) in [ + (InvitationError::Invalid("x".into()), StatusKind::Rejected), + ( + InvitationError::NetworkMismatch { + invitation: Network::Mainnet, + wallet: Network::Testnet, + }, + StatusKind::Rejected, + ), + ( + InvitationError::NotChainLocked("x".into()), + StatusKind::Unavailable, + ), + ( + InvitationError::Sdk(Error::TimeoutReached( + std::time::Duration::from_secs(1), + "t".into(), + )), + StatusKind::Unavailable, + ), + ] { + assert_eq!(Status::from(error).kind, kind); + } } /// Tor stream isolation; the C++ smoke covers the refusals. diff --git a/packages/rs-sdk-cxx/src/lib.rs b/packages/rs-sdk-cxx/src/lib.rs index c2b1a0ebdaf..459352ed872 100644 --- a/packages/rs-sdk-cxx/src/lib.rs +++ b/packages/rs-sdk-cxx/src/lib.rs @@ -19,6 +19,8 @@ mod signer; use std::panic::{catch_unwind, AssertUnwindSafe}; use client::PlatformClient; +use dash_sdk::dpp::dashcore::secp256k1::SecretKey; +use dash_sdk::platform::dashpay::{encode_invitation_uri, parse_invitation_uri, ParsedInvitation}; #[cxx::bridge(namespace = "dash_sdk")] pub mod ffi { @@ -319,6 +321,46 @@ pub mod ffi { profiles: Vec, } + /// An invitation's inviter; empty fields are absent. + #[derive(Debug, Clone, Default)] + struct InviterInfo { + username: String, + display_name: String, + avatar_url: String, + } + + /// What a link says, without its voucher key. `instant_lock`: it carries + /// an InstantSend lock, else the claim waits for a ChainLock. + /// `has_inviter` false: a funding voucher with no inviter. + /// `funding_txid` identifies the voucher: do not log it. + #[derive(Debug, Clone, Default)] + struct InvitationDetails { + funding_txid: String, + instant_lock: bool, + has_inviter: bool, + inviter: InviterInfo, + } + + /// The invitee's pre-claim view. `already_claimed` true is proved; + /// false does not prove the voucher unspent: a reclaim top-up spends it + /// without creating `prospective_identity`. `amount_duffs` is bound to + /// the link's txid, but `chain_locked` is the DAPI node's unproved word + /// and `instant` says only that the link carries an InstantSend lock. + #[derive(Debug, Clone, Default)] + struct InvitationClaim { + prospective_identity: [u8; 32], + amount_duffs: u64, + instant: bool, + chain_locked: bool, + already_claimed: bool, + } + + #[derive(Debug, Clone)] + struct InvitationClaimResult { + status: Status, + claim: InvitationClaim, + } + /// The DIP-15 compact extended public key, unvalidated beyond its /// length: the embedder checks `public_key` is a valid point. #[derive(Debug, Clone)] @@ -378,6 +420,11 @@ pub mod ffi { /// calls may run concurrently, each blocking its calling thread. type PlatformClient; + /// A parsed DashPay invitation link. Its voucher key, a bearer + /// secret, stays in Rust and is wiped when this is destroyed. + type Invitation; + fn details(self: &Invitation) -> InvitationDetails; + /// A rust::Error for a config it cannot honour: never a direct fallback. fn new_platform_client( config: &Config, @@ -476,6 +523,29 @@ pub mod ffi { signer: UniquePtr, keys: UniquePtr, ) -> IdResult; + /// Refetches the invitation's funding transaction and looks up the + /// identity a claim would create. A link for another network, or a + /// funding transaction the SDK refuses or cannot find yet, is + /// `Rejected`. + fn invitation_claim_status( + self: &PlatformClient, + invitation: &Invitation, + ) -> InvitationClaimResult; + /// Claims the invitation into a new identity holding `keys` (key 0 + /// a MASTER authentication key, each enabled). The signer signs + /// those keys; the voucher key signs the asset lock, in Rust. The + /// same `invitation` may be claimed again after a failure. Statuses + /// as `invitation_claim_status`, plus `Unavailable` when Platform + /// refused the link's InstantSend lock as stale and the funding + /// transaction is not ChainLocked yet. A link without an InstantSend + /// lock whose funding is not ChainLocked yet is `Rejected` by the + /// SDK, though it too only needs a later retry. + fn claim_invitation( + self: &PlatformClient, + invitation: &Invitation, + keys: &[IdentityKey], + signer: UniquePtr, + ) -> IdentityResult; /// Creates `owner`'s DashPay profile, signed with its key `key_id` /// (a HIGH or CRITICAL authentication key); the profile as Platform /// confirmed it. @@ -515,6 +585,18 @@ pub mod ffi { // 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; + /// The `dashpay://invite` link for an invitation funded by `funding`, + /// whose credit output pays `voucher_key`. The link holds the voucher + /// key in plain text: show it, never log or store it. An all-empty + /// `inviter` links no inviter. + fn encode_invitation( + voucher_key: &[u8; 32], + network: Network, + funding: &AssetLockProof, + inviter: &InviterInfo, + ) -> Result; + /// A rust::Error for a link that is not an invitation. + fn parse_invitation(uri: &str) -> Result>; /// Decrypts `encryptedPublicKey` with the wallet's ECDH secret. fn decrypt_contact_xpub( shared_secret: &[u8; 32], @@ -571,6 +653,66 @@ fn contested(check: fn(&str) -> bool, label: &str) -> bool { guarded("is_contested_username", || check(label), |_| true) } +pub struct Invitation(ParsedInvitation); + +impl Invitation { + /// A copy for one call's future, which must own what it uses (and the + /// SDK's claim takes the invitation by value); each copy wipes its key + /// when dropped. + pub(crate) fn copy(&self) -> ParsedInvitation { + ParsedInvitation { + voucher_key: self.0.voucher_key, + voucher_key_network: self.0.voucher_key_network, + funding_txid: self.0.funding_txid.clone(), + islock_hex: self.0.islock_hex.clone(), + inviter: self.0.inviter.clone(), + } + } + + fn details(&self) -> ffi::InvitationDetails { + guarded( + "Invitation::details", + || marshal::invitation_details(&self.0), + |_| Default::default(), + ) + } +} + +fn encode_invitation( + voucher_key: &[u8; 32], + network: ffi::Network, + funding: &ffi::AssetLockProof, + inviter: &ffi::InviterInfo, +) -> Result { + guarded( + "encode_invitation", + || { + let network = marshal::network(network)?; + let funding = marshal::asset_lock_proof(funding)?; + let mut key = SecretKey::from_secret_bytes(*voucher_key) + .map_err(|e| format!("bad voucher key: {e}"))?; + let encoded = + encode_invitation_uri(&key, network, &funding, marshal::inviter(inviter).as_ref()) + .map_err(|e| e.to_string()); + key.non_secure_erase(); + encoded + }, + Err, + ) +} + +fn parse_invitation(uri: &str) -> Result, String> { + guarded( + "parse_invitation", + || { + parse_invitation_uri(uri) + .map(|parsed| Box::new(Invitation(parsed))) + .map_err(|e| e.to_string()) + }, + Err, + ) +} + fn decrypt_contact_xpub( shared_secret: &[u8; 32], encrypted_public_key: &[u8], diff --git a/packages/rs-sdk-cxx/src/marshal.rs b/packages/rs-sdk-cxx/src/marshal.rs index c42526d4e60..18ab42a22a0 100644 --- a/packages/rs-sdk-cxx/src/marshal.rs +++ b/packages/rs-sdk-cxx/src/marshal.rs @@ -8,7 +8,7 @@ use std::collections::BTreeMap; use dash_sdk::dpp::dashcore::consensus::Decodable; use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::{InstantLock, OutPoint, ProTxHash, Transaction}; +use dash_sdk::dpp::dashcore::{InstantLock, Network, OutPoint, ProTxHash, 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; @@ -27,7 +27,7 @@ use dash_sdk::dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGett use dash_sdk::dpp::voting::votes::resource_vote::v0::ResourceVoteV0; use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; use dash_sdk::dpp::voting::votes::Vote; -use dash_sdk::platform::dashpay::ProfileFields; +use dash_sdk::platform::dashpay::{InviterInfo, ParsedInvitation, ProfileFields}; use dash_sdk::platform::dpns_usernames::{convert_to_homograph_safe_chars, DpnsUsername}; use dash_sdk::query_types::Contenders; @@ -47,6 +47,16 @@ pub fn pro_tx_hash(internal_order: &[u8; 32]) -> ProTxHash { ProTxHash::from_byte_array(reversed(internal_order)) } +pub fn network(network: ffi::Network) -> Result { + match network { + ffi::Network::Mainnet => Ok(Network::Mainnet), + ffi::Network::Testnet => Ok(Network::Testnet), + ffi::Network::Devnet => Ok(Network::Devnet), + ffi::Network::Regtest => Ok(Network::Regtest), + other => Err(format!("unknown network {}", other.repr)), + } +} + pub fn identity(identity: &Identity) -> ffi::Identity { ffi::Identity { id: identity.id().to_buffer(), @@ -87,6 +97,19 @@ pub fn new_identity_key(key: &ffi::IdentityKey) -> Result Result, String> { + let public_keys = keys + .iter() + .map(|key| new_identity_key(key).map(|key| (key.id(), key))) + .collect::, String>>()?; + (public_keys.len() == keys.len()) + .then_some(public_keys) + .ok_or_else(|| "duplicate identity key ids".to_string()) +} + pub fn asset_lock_proof(proof: &ffi::AssetLockProof) -> Result { match proof.kind { ffi::AssetLockProofKind::Instant => { @@ -155,6 +178,34 @@ pub fn empty_vote_state() -> ffi::VoteState { } } +/// `None` when every field is empty. +pub fn inviter(inviter: &ffi::InviterInfo) -> Option { + let info = InviterInfo { + username: non_empty(&inviter.username), + display_name: non_empty(&inviter.display_name), + avatar_url: non_empty(&inviter.avatar_url), + }; + (info.username.is_some() || info.display_name.is_some() || info.avatar_url.is_some()) + .then_some(info) +} + +pub fn invitation_details(invitation: &ParsedInvitation) -> ffi::InvitationDetails { + ffi::InvitationDetails { + funding_txid: invitation.funding_txid.clone(), + instant_lock: invitation.islock_hex.is_some(), + has_inviter: invitation.inviter.is_some(), + inviter: invitation + .inviter + .as_ref() + .map(|inviter| ffi::InviterInfo { + username: inviter.username.clone().unwrap_or_default(), + display_name: inviter.display_name.clone().unwrap_or_default(), + avatar_url: inviter.avatar_url.clone().unwrap_or_default(), + }) + .unwrap_or_default(), + } +} + /// The DPNS poll a vote on `label` goes to, as the SDK's DPNS contest /// queries build it, with the label normalized the same way. fn dpns_vote_poll(label: &str) -> ContestedDocumentResourceVotePoll { diff --git a/packages/rs-sdk-cxx/tests/cxx_smoke.cc b/packages/rs-sdk-cxx/tests/cxx_smoke.cc index 72fb8a23105..3b0d3ad20fd 100644 --- a/packages/rs-sdk-cxx/tests/cxx_smoke.cc +++ b/packages/rs-sdk-cxx/tests/cxx_smoke.cc @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -224,6 +225,44 @@ int main() StatusKind::Internal, "unknown vote choice accepted"); + // Invitations: a link round-trips through the codec, and the claim + // calls cross with the parsed handle; its voucher key never leaves Rust. + { + AssetLockProof funding{}; + funding.kind = AssetLockProofKind::Chain; + funding.core_chain_locked_height = 1; + // txid 00 01 .. 1f in consensus order, which displays reversed. + std::string display_txid; + for (int i = 31; i >= 0; --i) { + funding.out_point[i] = static_cast(i); + display_txid += "0123456789abcdef"[i >> 4]; + display_txid += "0123456789abcdef"[i & 15]; + } + std::array voucher{}; + voucher.fill(0x01); + InviterInfo inviter{}; + inviter.username = "alice"; + auto invitation = parse_invitation(encode_invitation(voucher, Network::Testnet, funding, inviter)); + const auto details = invitation->details(); + check(details.has_inviter && details.inviter.username == "alice" && details.inviter.display_name.empty() && + !details.instant_lock && std::string(details.funding_txid) == display_txid, + "an invitation did not round-trip"); + check(unreachable(client->invitation_claim_status(*invitation).status), "invitation_claim_status"); + check(unreachable(client->claim_invitation(*invitation, key_slice, signer(false)).status), "claim_invitation"); + + auto mainnet = parse_invitation(encode_invitation(voucher, Network::Mainnet, funding, InviterInfo{})); + check(!mainnet->details().has_inviter, "an empty inviter was linked"); + check(client->invitation_claim_status(*mainnet).status.kind == StatusKind::Rejected, + "a mainnet invitation was not refused by a testnet client"); + bool refused = false; + try { + parse_invitation("dashpay://invite?assetlocktx=11"); + } catch (const rust::Error&) { + refused = true; + } + check(refused, "a link without a voucher key was parsed"); + } + // No network. check(is_valid_username("alice") && !is_valid_username("-"), "is_valid_username"); check(is_contested_username("alice") && !is_contested_username("alice12345678901234567890"), From 5871f0a42af886cb0b4df58494fa7a3143a8cc31 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Oct 2026 06:33:29 -0500 Subject: [PATCH 11/20] fix(sdk): retry a CL-height rejection wrapped in the exhausted-address envelope as_asset_lock_proof_cl_height_too_low extracted the consensus error by hand and only saw StateTransitionBroadcastError and Protocol, so a 10506 inside NoAvailableAddressesToRetry was returned on the first attempt with no fee bump and no wait. Read it through Error::consensus_error(), as the sibling matchers in the file already do. Also rename the file's tests to start with "should" and import ProtocolError only in the test modules that use it. Co-Authored-By: Claude Opus 5.5 --- .../transition/asset_lock_submission.rs | 69 ++++++++++++++----- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs b/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs index 5641718364b..75336ed9443 100644 --- a/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs +++ b/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs @@ -18,7 +18,6 @@ use dpp::consensus::basic::identity::InvalidAssetLockProofCoreChainHeightError; use dpp::consensus::basic::BasicError; use dpp::consensus::ConsensusError; use dpp::dashcore::OutPoint; -use dpp::ProtocolError; use super::put_settings::PutSettings; use crate::Error; @@ -106,23 +105,19 @@ pub fn is_asset_lock_already_consumed(error: &Error, out_point: &OutPoint) -> bo /// Companion to [`is_instant_lock_proof_invalid`]; both feed the /// CL-proof retry path in the identity registration / top-up flow. /// -/// **Coverage caveat:** only inspects the two `Error` variants that today -/// wrap consensus errors — `StateTransitionBroadcastError` (from -/// `broadcast_and_wait`) and `Protocol(ProtocolError::ConsensusError)` -/// (from validation). If a future SDK version surfaces the same -/// `InvalidAssetLockProofCoreChainHeightError` through a different variant -/// (e.g. wrapped in a transport-layer error type), the retry helper -/// silently falls through to the passthrough. Re-audit this matcher -/// whenever `Error` gains new variants that can carry consensus errors. +/// **Coverage caveat:** the consensus error comes from +/// [`Error::consensus_error`], which sees `StateTransitionBroadcastError` +/// (from `broadcast_and_wait`), `Protocol(ProtocolError::ConsensusError)` +/// (from validation) and either of them inside the +/// `NoAvailableAddressesToRetry` envelope. If a future SDK version surfaces +/// the same `InvalidAssetLockProofCoreChainHeightError` through a different +/// variant, the retry helper silently falls through to the passthrough. +/// Re-audit [`Error::consensus_error`] whenever `Error` gains new variants +/// that can carry consensus errors. pub fn as_asset_lock_proof_cl_height_too_low( error: &Error, ) -> Option<&InvalidAssetLockProofCoreChainHeightError> { - let consensus_error = match error { - Error::StateTransitionBroadcastError(broadcast_err) => broadcast_err.cause.as_ref(), - Error::Protocol(ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), - _ => None, - }; - match consensus_error { + match error.consensus_error() { Some(ConsensusError::BasicError( BasicError::InvalidAssetLockProofCoreChainHeightError(e), )) => Some(e), @@ -270,6 +265,7 @@ fn bump_user_fee_increase(mut settings: PutSettings) -> PutSettings { #[cfg(test)] mod tests { use super::*; + use dpp::ProtocolError; /// Fabricate the SDK-side 10506 error shape exactly as /// `as_asset_lock_proof_cl_height_too_low` recognizes it. Both the @@ -307,7 +303,7 @@ mod tests { /// `tokio::time::advance` so the retry's `CL_HEIGHT_RETRY_DELAY` /// sleeps fire instantly and total test wall time is sub-millisecond. #[tokio::test(start_paused = true)] - async fn submit_with_cl_height_retry_bumps_user_fee_and_surfaces_after_budget() { + async fn should_bump_the_fee_on_each_retry_and_surface_the_error_after_the_budget() { use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; use tokio::sync::Mutex; @@ -388,6 +384,40 @@ mod tests { ); } } + + /// A 10506 that arrives inside the exhausted-address-retry envelope is + /// still a 10506: the retry bumps the fee and resubmits instead of + /// surfacing the error on the first attempt. + #[tokio::test(start_paused = true)] + async fn should_retry_cl_height_rejection_wrapped_in_exhausted_address_retry() { + use std::sync::{Arc, Mutex}; + + let seen: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let seen_in_submit = seen.clone(); + + let result = submit_with_cl_height_retry(None, move |settings| { + let seen = seen_in_submit.clone(); + async move { + let mut seen = seen.lock().unwrap(); + seen.push(settings.and_then(|s| s.user_fee_increase)); + if seen.len() == 1 { + Err(Error::NoAvailableAddressesToRetry(Box::new( + fabricate_cl_height_too_low_error(), + ))) + } else { + Ok(()) + } + } + }) + .await; + + assert!(result.is_ok(), "the retry must resubmit and succeed"); + assert_eq!( + *seen.lock().unwrap(), + vec![None, Some(1)], + "the second attempt must carry a bumped user_fee_increase" + ); + } } #[cfg(test)] @@ -398,6 +428,7 @@ mod asset_lock_already_consumed_tests { use dpp::consensus::basic::UnsupportedProtocolVersionError; use dpp::dashcore::hashes::Hash; use dpp::dashcore::Txid; + use dpp::ProtocolError; fn out_point() -> OutPoint { OutPoint::new(Txid::all_zeros(), 7) @@ -417,14 +448,14 @@ mod asset_lock_already_consumed_tests { } #[test] - fn recognizes_protocol_consensus_error_for_exact_outpoint() { + fn should_recognize_a_protocol_consensus_error_for_the_exact_outpoint() { let error = Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error()))); assert!(is_asset_lock_already_consumed(&error, &out_point())); } #[test] - fn recognizes_broadcast_consensus_error_for_exact_outpoint() { + fn should_recognize_a_broadcast_consensus_error_for_the_exact_outpoint() { let error = Error::StateTransitionBroadcastError(StateTransitionBroadcastError { code: 10504, message: "asset lock already consumed".to_string(), @@ -435,7 +466,7 @@ mod asset_lock_already_consumed_tests { } #[test] - fn ignores_unrelated_errors_and_different_outpoints() { + fn should_ignore_unrelated_errors_and_different_outpoints() { let error = Error::Protocol(ProtocolError::ConsensusError(Box::new(consensus_error()))); let different_out_point = OutPoint::new(Txid::all_zeros(), 8); From eac9f8c78513291d8d770a29eca25c857179b400 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Oct 2026 06:33:29 -0500 Subject: [PATCH 12/20] fix(sdk): report invitation retry-later conditions as NotChainLocked Two retry-later conditions were reported as InvitationError::Invalid, so the wallet mapped them to InvalidIdentityData while the stale-islock case mapped to AssetLockNotChainLocked. An embedder could only tell "invalid link" from "retry later" by matching on the message. Both now use NotChainLocked: - an islock-less invitation whose funding transaction is not chain-locked yet; - a funding transaction no queried node has returned after every attempt (fetch_funding_tx_with_retry now returns the error instead of Ok(None)). NotChainLocked displays its message as is rather than repeating it after a prefix. Fix the fetch_funding_tx_with_retry doc, which still named tokio::time::sleep, and rename the invitation tests to start with "should". Pin the NotChainLocked cases and the InvitationError to PlatformWalletError mapping in tests. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/src/error.rs | 35 ++++ .../rs-sdk/src/platform/dashpay/invitation.rs | 176 ++++++++++-------- 2 files changed, 129 insertions(+), 82 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 870e9be0323..40d08e0b273 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1753,3 +1753,38 @@ mod identity_insufficient_balance_tests { } } } + +#[cfg(test)] +mod invitation_error_mapping_tests { + use super::*; + + /// The retry-later signal keeps its own wallet variant, apart from a link + /// that is simply invalid, so hosts can tell "try again" from "reject". + #[test] + fn should_map_each_invitation_error_to_its_wallet_variant() { + assert!(matches!( + PlatformWalletError::from(InvitationError::Invalid("bad link".to_string())), + PlatformWalletError::InvalidIdentityData(message) if message == "bad link" + )); + assert!(matches!( + PlatformWalletError::from(InvitationError::NotChainLocked("later".to_string())), + PlatformWalletError::AssetLockNotChainLocked(message) if message == "later" + )); + assert!(matches!( + PlatformWalletError::from(InvitationError::NetworkMismatch { + invitation: Network::Testnet, + wallet: Network::Mainnet, + }), + PlatformWalletError::InvitationNetworkMismatch { + invitation: Network::Testnet, + wallet: Network::Mainnet, + } + )); + assert!(matches!( + PlatformWalletError::from(InvitationError::Sdk(dash_sdk::Error::Generic( + "boom".to_string() + ))), + PlatformWalletError::Sdk(dash_sdk::Error::Generic(message)) if message == "boom" + )); + } +} diff --git a/packages/rs-sdk/src/platform/dashpay/invitation.rs b/packages/rs-sdk/src/platform/dashpay/invitation.rs index 70c490a0a33..785b7bfae9e 100644 --- a/packages/rs-sdk/src/platform/dashpay/invitation.rs +++ b/packages/rs-sdk/src/platform/dashpay/invitation.rs @@ -150,10 +150,12 @@ pub enum InvitationError { wallet: Network, }, - /// Platform rejected the link's islock as stale and the funding - /// transaction is not chain-locked yet, so no ChainLock proof can replace - /// it. Retry once the funding block is chain-locked. - #[error("invitation funding transaction is not chain-locked: {0}")] + /// Retry later: the link may be fine but cannot be used yet. Either the + /// funding transaction has not reached the queried node, or it is not + /// chain-locked yet and the claim needs a ChainLock proof (the link has + /// no islock, or Platform rejected its islock as stale). The message + /// says which. + #[error("{0}")] NotChainLocked(String), /// An SDK call failed. @@ -937,7 +939,7 @@ async fn inspect_invitation_funding( /// Fetch the invitation's funding transaction with the bounded propagation /// retry (see [`fetch_funding_tx_with_retry`]); a miss after every attempt -/// is an error. +/// is [`InvitationError::NotChainLocked`], a retry-later signal. async fn fetch_funding_tx( sdk: &Sdk, invitation: &ParsedInvitation, @@ -947,20 +949,14 @@ async fn fetch_funding_tx( |txid| async move { Ok(sdk.get_transaction(&txid).await?) }, || rs_dapi_client::transport::sleep(CLAIM_FETCH_RETRY_DELAY), ) - .await? - .ok_or_else(|| { - invalid( - "invitation funding transaction not found (tried both byte orders across \ - repeated attempts); it may not have propagated to the queried DAPI node yet — \ - retry shortly", - ) - }) + .await } /// Fetch the claim's funding transaction with the bounded propagation retry — /// the injectable orchestration seam of [`fetch_funding_tx`] (tests -/// script `fetch`/`delay`; production passes `Sdk::get_transaction` and a -/// `tokio::time::sleep`). +/// script `fetch`/`delay`; production passes `Sdk::get_transaction` and +/// `rs_dapi_client::transport::sleep`, which also runs in the browser where +/// `tokio::time::sleep` does not). /// /// Two independent concerns are layered here: /// 1. Byte order — old iOS links carry the txid little-endian, so a @@ -975,12 +971,15 @@ async fn fetch_funding_tx( /// attempts — and none after the last (the caller surfaces the miss /// immediately). /// -/// Returns `Ok(None)` only after every attempt missed on both byte orders. +/// Every attempt missing on both byte orders is +/// [`InvitationError::NotChainLocked`]: the transaction may simply not have +/// reached the queried node yet, so the caller should retry later rather than +/// treat the link as invalid. async fn fetch_funding_tx_with_retry( funding_txid: &str, mut fetch: F, mut delay: D, -) -> Result, InvitationError> +) -> Result where F: FnMut(String) -> FFut, FFut: std::future::Future, InvitationError>>, @@ -989,19 +988,24 @@ where { for attempt in 0..CLAIM_FETCH_MAX_ATTEMPTS { if let Some(tx) = fetch(funding_txid.to_string()).await? { - return Ok(Some(tx)); + return Ok(tx); } // Reversed lookup is computed lazily on a canonical miss, so a hit // never depends on the txid being reversible hex. let reversed = reverse_txid_hex(funding_txid)?; if let Some(tx) = fetch(reversed).await? { - return Ok(Some(tx)); + return Ok(tx); } if attempt + 1 < CLAIM_FETCH_MAX_ATTEMPTS { delay().await; } } - Ok(None) + Err(InvitationError::NotChainLocked( + "invitation funding transaction not found (tried both byte orders across \ + repeated attempts); it may not have propagated to the queried DAPI node yet — \ + retry shortly" + .to_string(), + )) } /// Submit the claim's reconstructed proof with the stale-islock → ChainLock @@ -1282,7 +1286,7 @@ fn assemble_asset_lock_proof( // chain-locked otherwise. There is no separate fallback — this IS the // ChainLock proof. if !is_chain_locked { - return Err(InvitationError::Invalid( + return Err(InvitationError::NotChainLocked( "chainlock invitation funding transaction is not yet chain-locked; \ retry once it confirms" .to_string(), @@ -1350,7 +1354,7 @@ mod tests { } #[test] - fn wif_round_trip_preserves_compression_and_network() { + fn should_round_trip_a_wif_keeping_compression_and_network() { for (network, first_byte) in [(Network::Mainnet, 204u8), (Network::Testnet, 239u8)] { let wif = PrivateKey::new(voucher(), network).to_wif(); let decoded = PrivateKey::from_wif(&wif).expect("wif decodes"); @@ -1364,7 +1368,7 @@ mod tests { } #[test] - fn encode_parse_round_trip_with_inviter() { + fn should_round_trip_an_encoded_link_with_inviter_info() { let proof = instant_proof_paying_voucher(); let info = inviter_info(); let uri = encode_invitation_uri(&voucher(), Network::Testnet, &proof, Some(&info)) @@ -1387,7 +1391,7 @@ mod tests { } #[test] - fn encode_parse_round_trip_pure_voucher_no_inviter() { + fn should_round_trip_an_encoded_link_without_inviter_info() { let proof = instant_proof_paying_voucher(); let uri = encode_invitation_uri(&voucher(), Network::Mainnet, &proof, None).expect("encode"); @@ -1399,7 +1403,7 @@ mod tests { /// Params in a non-canonical order must still parse (field-level, not /// byte-level, interop — the two legacy wallets differ in order). #[test] - fn parse_is_order_independent() { + fn should_parse_a_link_regardless_of_field_order() { let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); let uri = format!("dashpay://invite?islock=deadbeef&pk={wif}&du=bob&assetlocktx=aabbcc"); let parsed = parse_invitation_uri(&uri).expect("parse"); @@ -1414,7 +1418,7 @@ mod tests { /// The `https://invitations.dashpay.io/applink` host is accepted as a /// first-class alternative to the custom scheme (iOS emits it). #[test] - fn parse_accepts_applink_host() { + fn should_parse_a_link_on_the_applink_host() { let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); let uri = format!( "https://invitations.dashpay.io/applink?du=carol&assetlocktx=aabb&pk={wif}&islock=cc" @@ -1431,7 +1435,7 @@ mod tests { /// `Some`; both a missing param and the literal `"null"` yield `None` /// (a ChainLock-confirmed invite Android's own validator accepts). #[test] - fn parse_islock_present_absent_and_null() { + fn should_parse_an_islock_that_is_present_absent_or_null() { let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); let present = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=00aa11"); assert_eq!( @@ -1454,7 +1458,7 @@ mod tests { /// A `du`-less link still parses (iOS accepts du-less links). #[test] - fn parse_accepts_du_less_link() { + fn should_parse_a_link_without_a_du_field() { let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); let uri = format!("dashpay://invite?assetlocktx=aa&pk={wif}&islock=bb"); let parsed = parse_invitation_uri(&uri).expect("parse"); @@ -1462,7 +1466,7 @@ mod tests { } #[test] - fn parse_rejects_missing_pk_or_assetlocktx() { + fn should_reject_a_link_missing_pk_or_assetlocktx() { let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); // No pk. assert!(parse_invitation_uri("dashpay://invite?assetlocktx=aa").is_err()); @@ -1473,13 +1477,13 @@ mod tests { } #[test] - fn parse_rejects_wrong_scheme_and_host() { + fn should_reject_a_link_with_the_wrong_scheme_or_host() { assert!(parse_invitation_uri("https://example.com/foo?pk=x").is_err()); assert!(parse_invitation_uri("dashpay://contact?pk=x").is_err()); } #[test] - fn parse_rejects_uncompressed_wif() { + fn should_reject_an_uncompressed_wif() { let uncompressed = PrivateKey::new_uncompressed(voucher(), Network::Testnet).to_wif(); let uri = format!("dashpay://invite?assetlocktx=aa&pk={uncompressed}"); let err = parse_invitation_uri(&uri).unwrap_err(); @@ -1487,13 +1491,13 @@ mod tests { } #[test] - fn parse_rejects_bad_wif() { + fn should_reject_a_malformed_wif() { let err = parse_invitation_uri("dashpay://invite?assetlocktx=aa&pk=not-a-wif").unwrap_err(); assert!(err.to_string().contains("WIF")); } #[test] - fn parse_rejects_oversized_uri() { + fn should_reject_an_oversized_uri() { let huge = format!("dashpay://invite?pk={}", "z".repeat(MAX_INVITATION_URI_LEN)); let err = parse_invitation_uri(&huge).unwrap_err(); assert!(err.to_string().contains("too long")); @@ -1502,7 +1506,7 @@ mod tests { /// A hand-crafted Android-style link (params in Android's emit order, with /// display-name + avatar-url) parses to the right fields. #[test] - fn parse_android_style_link() { + fn should_parse_an_android_style_link() { let wif = PrivateKey::new(voucher(), Network::Mainnet).to_wif(); // Android order: du, assetlocktx, pk, islock, display-name, avatar-url. let uri = format!( @@ -1527,7 +1531,7 @@ mod tests { /// The voucher output is selected by pk↔script match, not hard-coded to 0. #[test] - fn voucher_output_index_selects_matching_output() { + fn should_select_the_output_that_pays_the_voucher_key() { let key = voucher(); // Voucher output sits at index 2, behind two decoy outputs. let tx = asset_lock_tx_paying_voucher_at(&key, 2); @@ -1547,7 +1551,7 @@ mod tests { } #[test] - fn voucher_output_index_rejects_no_match() { + fn should_reject_a_funding_tx_with_no_output_paying_the_voucher() { let key = voucher(); let other = SecretKey::from_secret_bytes([0x33u8; 32]).unwrap(); let tx = asset_lock_tx_paying_voucher_at(&other, 0); @@ -1556,7 +1560,7 @@ mod tests { } #[test] - fn voucher_output_index_rejects_non_asset_lock_tx() { + fn should_reject_a_funding_tx_that_is_not_an_asset_lock() { let key = voucher(); let tx = Transaction { version: 2, @@ -1571,7 +1575,7 @@ mod tests { /// A `du`-less link that still carries `display-name`/`avatar-url` must /// surface them (the reference wallets emit those independently of `du`). #[test] - fn parse_du_less_link_keeps_metadata() { + fn should_keep_inviter_metadata_when_the_du_field_is_missing() { let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); let uri = format!( "dashpay://invite?assetlocktx=aa&pk={wif}&display-name=No%20User&avatar-url=https%3A%2F%2Fimg%2Fx.png" @@ -1586,7 +1590,7 @@ mod tests { /// A duplicated required key is ambiguous (iOS binds the last, Android the /// first) — reject rather than claim a wallet-dependent key. #[test] - fn parse_rejects_duplicate_required_keys() { + fn should_reject_a_link_with_duplicate_required_keys() { let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); let other = PrivateKey::new( SecretKey::from_secret_bytes([0x44u8; 32]).unwrap(), @@ -1611,7 +1615,7 @@ mod tests { /// host in its own query must both be rejected — otherwise the decoy's query /// (with a `pk`/`assetlocktx`) would be parsed. #[test] - fn parse_rejects_decoy_scheme_and_host() { + fn should_reject_a_link_with_a_decoy_scheme_or_host() { let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); // Suffixed authority — not exactly `invite`. let suffixed = format!("dashpay://inviteXYZ?assetlocktx=aa&pk={wif}"); @@ -1630,7 +1634,7 @@ mod tests { /// WIF network compatibility is mainnet-vs-not: a testnet WIF is rejected on /// a mainnet wallet, but accepted on any testnet-family wallet. #[test] - fn wif_network_matches_is_mainnet_vs_testnet_family() { + fn should_match_wif_networks_by_mainnet_versus_testnet_family() { assert!(wif_network_matches(Network::Mainnet, Network::Mainnet)); assert!(wif_network_matches(Network::Testnet, Network::Testnet)); // WIF decodes 0xEF to Testnet; a devnet/regtest wallet must still accept. @@ -1660,7 +1664,7 @@ mod tests { /// A backend answering with an unrelated tx (id matches neither byte order) is /// rejected before any proof is built. #[test] - fn assemble_rejects_txid_mismatch() { + fn should_reject_a_fetched_tx_whose_id_does_not_match() { let key = voucher(); let tx = asset_lock_tx_paying_voucher_at(&key, 0); let inv = parsed(key, "00".repeat(32), None); @@ -1669,22 +1673,26 @@ mod tests { } /// A ChainLock invite (no islock) whose funding tx is not yet chain-locked is - /// rejected — the proof must prove finality. + /// not claimable yet — the proof must prove finality — and that is the + /// retry-later signal, not an invalid link. #[test] - fn assemble_chainlock_requires_chain_lock() { + fn should_ask_to_retry_a_chainlock_invitation_until_it_is_chain_locked() { let key = voucher(); let tx = asset_lock_tx_paying_voucher_at(&key, 0); let txid = tx.txid().to_string(); let inv = parsed(key, txid, None); let err = assemble_asset_lock_proof(tx, false, 100, &inv).unwrap_err(); - assert!(format!("{err}").contains("not yet chain-locked")); + assert!( + matches!(err, InvitationError::NotChainLocked(_)), + "expected the retry-later signal, got {err:?}" + ); } /// A chain-locked ChainLock invite (no islock) assembles a ChainLock proof at /// the tx's voucher output, with no separate fallback (the primary IS the CL /// proof). #[test] - fn assemble_chainlock_ok_when_locked() { + fn should_assemble_a_chainlock_proof_once_chain_locked() { let key = voucher(); let tx = asset_lock_tx_paying_voucher_at(&key, 0); let txid = tx.txid().to_string(); @@ -1702,7 +1710,7 @@ mod tests { /// same credit output — the stale-islock recovery the claim path submits if /// Platform rejects the IS proof with `InvalidInstantAssetLockProofSignatureError`. #[test] - fn assemble_islock_present_and_chainlocked_carries_chain_fallback() { + fn should_carry_a_chain_fallback_for_a_chain_locked_islock_invitation() { let key = voucher(); let tx = asset_lock_tx_paying_voucher_at(&key, 0); let txid = tx.txid().to_string(); @@ -1729,7 +1737,7 @@ mod tests { /// no ChainLock proof to fall back to, and the claim must be retried once the /// block confirms. #[test] - fn assemble_islock_present_not_chainlocked_has_no_fallback() { + fn should_have_no_chain_fallback_for_an_islock_invitation_that_is_not_chain_locked() { let key = voucher(); let tx = asset_lock_tx_paying_voucher_at(&key, 0); let txid = tx.txid().to_string(); @@ -1755,7 +1763,7 @@ mod tests { /// "has this invitation been used?" check answers about a stranger's /// identity and reports a perfectly good voucher as spent. #[test] - fn prospective_id_follows_the_selected_credit_output() { + fn should_derive_the_prospective_id_from_the_selected_credit_output() { let key = voucher(); let decoy = SecretKey::from_secret_bytes([0x22u8; 32]).unwrap(); let payload = AssetLockPayload { @@ -1817,7 +1825,7 @@ mod tests { /// would otherwise assume encodes "spent": it does not, and Platform /// exposes no client query for spent asset locks to fill the gap. #[test] - fn prospective_id_is_outpoint_derived_and_says_nothing_about_consumption() { + fn should_derive_the_prospective_id_from_the_outpoint_alone() { let key = voucher(); let payload = AssetLockPayload { version: 1, @@ -1971,7 +1979,7 @@ mod tests { use std::cell::Cell; use super::super::adopt_already_landed_claim; - use super::super::InvitationError; + use super::super::{Error, InvitationError}; use super::{identity_with_keys, our_keys, test_key}; use dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError; use dpp::consensus::basic::BasicError; @@ -1987,7 +1995,7 @@ mod tests { /// Platform's consumed-outpoint rejection for `out_point`. fn consumed(out_point: OutPoint) -> InvitationError { - InvitationError::Sdk(crate::Error::Protocol(dpp::ProtocolError::ConsensusError( + InvitationError::Sdk(Error::Protocol(dpp::ProtocolError::ConsensusError( Box::new(ConsensusError::BasicError( BasicError::IdentityAssetLockTransactionOutPointAlreadyConsumedError( IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( @@ -2000,13 +2008,13 @@ mod tests { } fn is_consumed(result: &Result) -> bool { - matches!(result, Err(InvitationError::Sdk(crate::Error::Protocol(_)))) + matches!(result, Err(InvitationError::Sdk(Error::Protocol(_)))) } /// Run the seam with a scripted lookup, counting how often it is called. async fn adopt( submitted: Result, - lookup: Result, crate::Error>, + lookup: Result, Error>, ) -> (Result, u32) { let calls = Cell::new(0); let keys = our_keys(); @@ -2061,7 +2069,7 @@ mod tests { async fn should_keep_the_rejection_when_the_lookup_fails() { let (result, _) = adopt( Err(consumed(out_point())), - Err(crate::Error::Generic("timeout".to_string())), + Err(Error::Generic("timeout".to_string())), ) .await; assert!(is_consumed(&result)); @@ -2083,7 +2091,7 @@ mod tests { #[tokio::test] async fn should_pass_unrelated_errors_through_without_a_lookup() { let (result, lookups) = adopt( - Err(InvitationError::Sdk(crate::Error::Generic( + Err(InvitationError::Sdk(Error::Generic( "unrelated".to_string(), ))), Ok(Some(identity_with_keys(our_keys()))), @@ -2091,7 +2099,7 @@ mod tests { .await; assert!(matches!( result, - Err(InvitationError::Sdk(crate::Error::Generic(_))) + Err(InvitationError::Sdk(Error::Generic(_))) )); assert_eq!(lookups, 0); } @@ -2100,7 +2108,7 @@ mod tests { /// An islock that locks a DIFFERENT tx than the funding tx is rejected (the /// txid-binding guard), so a link can't pair a valid islock with a foreign tx. #[test] - fn assemble_rejects_islock_for_wrong_tx() { + fn should_reject_an_islock_that_locks_another_tx() { let key = voucher(); let tx = asset_lock_tx_paying_voucher_at(&key, 0); let txid = tx.txid().to_string(); @@ -2150,7 +2158,7 @@ mod tests { async fn run( script: Vec, InvitationError>>, - ) -> (Result, InvitationError>, Vec, u32) { + ) -> (Result, Vec, u32) { let canonical = canonical_txid(); let (queue, calls, delays) = harness(script); let (queue_f, calls_f, delays_f) = @@ -2184,9 +2192,9 @@ mod tests { /// A first-call canonical hit returns immediately: one lookup, no /// reversed fallback, no delay. #[tokio::test] - async fn canonical_hit_returns_immediately() { + async fn should_return_a_canonical_hit_immediately() { let (result, calls, delays) = run(vec![Ok(Some(7))]).await; - assert_eq!(result.expect("no error"), Some(7)); + assert_eq!(result.expect("no error"), 7); assert_eq!(calls, vec![canonical_txid()]); assert_eq!(delays, 0); } @@ -2195,9 +2203,9 @@ mod tests { /// same attempt (the legacy-iOS little-endian compatibility path) — no /// delay is spent on the byte-order fallback. #[tokio::test] - async fn reversed_hit_within_same_attempt_no_delay() { + async fn should_try_the_reversed_txid_in_the_same_attempt_without_a_delay() { let (result, calls, delays) = run(vec![Ok(None), Ok(Some(9))]).await; - assert_eq!(result.expect("no error"), Some(9)); + assert_eq!(result.expect("no error"), 9); assert_eq!( calls, vec![ @@ -2211,24 +2219,28 @@ mod tests { /// A hit on a later attempt (propagation lag) succeeds after exactly /// one delay per fully-missed attempt. #[tokio::test] - async fn hit_after_missed_attempts_counts_one_delay_per_miss() { + async fn should_wait_once_per_fully_missed_attempt_before_a_hit() { // Attempts 1 and 2 miss on both orders; attempt 3 hits canonically. let (result, calls, delays) = run(vec![Ok(None), Ok(None), Ok(None), Ok(None), Ok(Some(3))]).await; - assert_eq!(result.expect("no error"), Some(3)); + assert_eq!(result.expect("no error"), 3); assert_eq!(calls.len(), 5); assert_eq!(delays, 2); } - /// Exhausting every attempt returns Ok(None) — a miss, not an error — - /// after 2 lookups per attempt, with NO delay after the final attempt. + /// Exhausting every attempt is the retry-later signal (the tx may not + /// have reached the node yet), not an invalid link, after 2 lookups per + /// attempt, with NO delay after the final attempt. #[tokio::test] - async fn exhausts_attempts_without_trailing_delay() { + async fn should_ask_to_retry_later_after_every_attempt_misses() { let script = (0..CLAIM_FETCH_MAX_ATTEMPTS * 2) .map(|_| Ok(None)) .collect(); let (result, calls, delays) = run(script).await; - assert_eq!(result.expect("misses are not errors"), None); + assert!( + matches!(result, Err(InvitationError::NotChainLocked(_))), + "a funding tx that never appears must be a retry-later signal, got {result:?}" + ); assert_eq!(calls.len(), (CLAIM_FETCH_MAX_ATTEMPTS * 2) as usize); assert_eq!(delays, CLAIM_FETCH_MAX_ATTEMPTS - 1); } @@ -2236,7 +2248,7 @@ mod tests { /// A transport error is NOT masked as a miss: it propagates /// immediately, with no further lookups and no extra delay. #[tokio::test] - async fn transport_error_propagates_immediately() { + async fn should_propagate_a_transport_error_immediately() { // Attempt 1 misses both orders (1 delay); attempt 2's canonical // lookup errors. let (result, calls, delays) = run(vec![ @@ -2263,7 +2275,7 @@ mod tests { use std::sync::{Arc, Mutex}; use super::super::submit_claim_with_stale_islock_fallback; - use super::super::InvitationError; + use super::super::{Error, InvitationError}; use super::{asset_lock_tx_paying_voucher_at, voucher}; use dpp::consensus::basic::identity::InvalidInstantAssetLockProofSignatureError; use dpp::dashcore::InstantLock; @@ -2274,15 +2286,15 @@ mod tests { /// The one rejection the seam recovers from: Platform's stale-islock /// consensus error, in the `Protocol(ConsensusError)` shape /// `is_instant_lock_proof_invalid` matches. - fn stale_islock_rejection() -> crate::Error { - crate::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + fn stale_islock_rejection() -> Error { + Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( InvalidInstantAssetLockProofSignatureError::new().into(), ))) } /// Any error the seam must NOT treat as a stale islock. - fn unrelated_sdk_error() -> crate::Error { - crate::Error::Config("simulated unrelated rejection".to_string()) + fn unrelated_sdk_error() -> Error { + Error::Config("simulated unrelated rejection".to_string()) } /// An InstantSend primary — the variant is what the assertions key on, @@ -2303,9 +2315,9 @@ mod tests { /// it was handed and pops the next scripted result. async fn run( chain_fallback: Option, - script: Vec>, + script: Vec>, ) -> (Result, Vec) { - let queue: Arc>>> = + let queue: Arc>>> = Arc::new(Mutex::new(script.into_iter().collect())); let submitted: Arc>> = Arc::new(Mutex::new(Vec::new())); let (queue_f, submitted_f) = (Arc::clone(&queue), Arc::clone(&submitted)); @@ -2333,7 +2345,7 @@ mod tests { /// A successful primary submission returns immediately: one submission, /// and the ChainLock fallback is never touched even though it exists. #[tokio::test] - async fn primary_success_never_submits_fallback() { + async fn should_not_submit_the_fallback_when_the_primary_succeeds() { let (result, submitted) = run(Some(chain_fallback_proof()), vec![Ok(7)]).await; assert_eq!(result.expect("primary success"), 7); assert_eq!(submitted.len(), 1, "exactly one submission"); @@ -2347,7 +2359,7 @@ mod tests { /// fallback over the same outpoint, and the fallback's success is the /// claim's success. #[tokio::test] - async fn stale_islock_resubmits_chain_fallback() { + async fn should_resubmit_the_chain_fallback_after_a_stale_islock() { let (result, submitted) = run( Some(chain_fallback_proof()), vec![Err(stale_islock_rejection()), Ok(9)], @@ -2369,7 +2381,7 @@ mod tests { /// chain-locked) surfaces `InvitationError::NotChainLocked` — the clear retry /// signal — after exactly one submission. #[tokio::test] - async fn stale_islock_without_fallback_surfaces_retry_signal() { + async fn should_ask_to_retry_when_a_stale_islock_has_no_chain_fallback() { let (result, submitted) = run(None, vec![Err(stale_islock_rejection())]).await; assert!( matches!(result, Err(InvitationError::NotChainLocked(_))), @@ -2385,7 +2397,7 @@ mod tests { /// An unrelated SDK error propagates unchanged after one submission: /// the fallback exists but must NOT be tried for it. #[tokio::test] - async fn unrelated_error_propagates_without_second_submission() { + async fn should_propagate_an_unrelated_error_without_a_second_submission() { let (result, submitted) = run( Some(chain_fallback_proof()), vec![Err(unrelated_sdk_error())], @@ -2406,7 +2418,7 @@ mod tests { /// one wearing the stale-islock shape again — propagates rather than /// looping. #[tokio::test] - async fn fallback_rejection_is_not_recovered_again() { + async fn should_not_recover_a_rejected_fallback_again() { let (result, submitted) = run( Some(chain_fallback_proof()), vec![Err(stale_islock_rejection()), Err(stale_islock_rejection())], From 488d2857d7debd9829b98a5c4d7dd82d50818881 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Oct 2026 06:43:07 -0500 Subject: [PATCH 13/20] feat(sdk): validate DashPay profile fields before signing and chunk get_profiles create_profile rejects, before the nonce is reserved or anything is broadcast, a profile with no field, an empty text field, or only some of the avatar URL, hash and fingerprint (the schema's dependentRequired and minProperties/minLength rules). update_profile rejects an empty text field, a lone avatar hash, and a changed avatar_url sent without its hashes; an unchanged URL may be resent without them and keeps the stored hashes. get_profiles now splits the owners into queries of at most 100 itself instead of refusing more, and create_profile/get_profiles check that the passed contract is the DashPay system contract. The queries use the DocumentQuery builder and update_profile reads the existing profile through get_profiles. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk/src/platform/dashpay/profile.rs | 548 ++++++++++++++---- 1 file changed, 435 insertions(+), 113 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/profile.rs b/packages/rs-sdk/src/platform/dashpay/profile.rs index 4aeedf04dde..1148aab8ef6 100644 --- a/packages/rs-sdk/src/platform/dashpay/profile.rs +++ b/packages/rs-sdk/src/platform/dashpay/profile.rs @@ -22,23 +22,29 @@ use dpp::identity::IdentityPublicKey; use dpp::platform_value::{platform_value, Value}; use dpp::prelude::Identifier; use drive::query::{OrderClause, WhereClause, WhereOperator}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; const PROFILE_DOCUMENT_TYPE: &str = "profile"; -/// Most owners one [`Sdk::get_profiles`] call can look up: the Platform `In` -/// clause cardinality cap, which is also the document-query limit. +/// The profile schema requires these three together (`dependentRequired`). +const AVATAR_KEYS: [&str; 3] = ["avatarUrl", "avatarHash", "avatarFingerprint"]; + +/// Most owners one profile query can carry: the Platform `In` clause +/// cardinality cap, which is also the document-query limit. pub const MAX_PROFILE_OWNERS_PER_QUERY: usize = 100; /// The profile fields a caller sets. `None` leaves the field alone on /// [`Sdk::update_profile`] and omits it on [`Sdk::create_profile`]. /// /// `avatar_hash` and `avatar_fingerprint` are computed by the caller from the -/// avatar image bytes and set together with a new `avatar_url`. +/// avatar image bytes. The schema requires the three avatar fields together: +/// a create sets all three or none, and an update that changes `avatar_url` +/// must set both hashes with it. The two hashes are always set together, and a +/// text field that is set is not empty. A field cannot be cleared. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ProfileFields { - /// Display name (max 25 chars per DIP-15) + /// Display name (1 to 25 chars per DIP-15) pub display_name: Option, /// Public message, the bio (max 140 chars per DIP-15) pub public_message: Option, @@ -74,32 +80,80 @@ impl ProfileFields { } properties } + + /// Refuse, before anything is signed, a create the schema would reject + /// after the broadcast: no field at all, or an empty text field. + fn ensure_valid_create(&self) -> Result<(), Error> { + if *self == ProfileFields::default() { + return Err(Error::Generic( + "a profile needs at least one field".to_string(), + )); + } + self.ensure_text_fields_not_empty() + } + + /// Refuse, before anything is read or signed, an update that replaces only + /// one of the two avatar hashes, or sets an empty text field. A changed + /// `avatar_url` needs its hashes too; that is checked against the existing + /// document in [`replacement_document`]. + fn ensure_valid_update(&self) -> Result<(), Error> { + if self.avatar_hash.is_some() != self.avatar_fingerprint.is_some() { + return Err(Error::Generic( + "avatarHash and avatarFingerprint must be set together".to_string(), + )); + } + self.ensure_text_fields_not_empty() + } + + /// Every profile text field has `minLength` 1. + fn ensure_text_fields_not_empty(&self) -> Result<(), Error> { + let text_fields = [ + ("displayName", &self.display_name), + ("publicMessage", &self.public_message), + ("avatarUrl", &self.avatar_url), + ]; + match text_fields + .into_iter() + .find(|(_, value)| value.as_deref() == Some("")) + { + Some((name, _)) => Err(Error::Generic(format!("{name} must not be empty"))), + None => Ok(()), + } + } } impl Sdk { - /// Fetch the `profile` documents of `owner_ids` (unique, at most - /// [`MAX_PROFILE_OWNERS_PER_QUERY`]), keyed by owner id. An owner without a - /// profile is missing from the result. More than that many owners, or a - /// duplicated owner, is rejected with [`Error::Generic`] before any query. + /// Fetch the `profile` documents of `owner_ids` (unique), keyed by owner + /// id, in queries of at most 100 owners each. An owner without a profile + /// is missing from the result. A duplicated owner is rejected with + /// [`Error::Generic`] before any query. /// - /// `dashpay_contract` is the DashPay data contract the caller resolved. + /// `dashpay_contract` must be the DashPay system data contract. pub async fn get_profiles( &self, dashpay_contract: &Arc, owner_ids: &[Identifier], ) -> Result, Error> { - ensure_valid_profile_owners(owner_ids)?; - if owner_ids.is_empty() { - return Ok(BTreeMap::new()); + self.ensure_dashpay_contract(dashpay_contract)?; + ensure_unique_owners(owner_ids)?; + let mut profiles = BTreeMap::new(); + for chunk in owner_ids.chunks(MAX_PROFILE_OWNERS_PER_QUERY) { + profiles.extend( + self.fetch_profile_documents(profiles_query(dashpay_contract, chunk)?) + .await?, + ); } - self.fetch_profile_documents(profiles_query(dashpay_contract, owner_ids)) - .await + Ok(profiles) } /// Create the profile document of `owner_id` from `fields`. /// - /// `signing_key` must be a HIGH or CRITICAL authentication key of the owner. - /// Returns the document as confirmed by Platform. + /// `fields` must set at least one field, no empty text field, and all of + /// the avatar fields or none; otherwise the call fails before anything is + /// signed. + /// + /// `signing_key` must be a HIGH or CRITICAL authentication key of the + /// owner. Returns the document as confirmed by Platform. pub async fn create_profile>( &self, dashpay_contract: &Arc, @@ -108,7 +162,8 @@ impl Sdk { signing_key: &IdentityPublicKey, signer: &S, ) -> Result { - let properties = sanitized_properties(dashpay_contract, fields.overlay(BTreeMap::new()))?; + self.ensure_dashpay_contract(dashpay_contract)?; + let properties = new_profile_properties(dashpay_contract, fields)?; let entropy: [u8; 32] = StdRng::from_os_rng().random(); let id = Document::generate_document_id_v0( @@ -133,9 +188,14 @@ impl Sdk { /// `fields` that are set so a partial update keeps the other fields, /// increment the revision and replace it. /// - /// `signing_key` must be a HIGH or CRITICAL authentication key of the owner. - /// Returns the merged document as confirmed by Platform. Fails if the owner - /// has no profile. + /// Text fields that are set must not be empty, the two avatar hashes are set + /// together, and a changed `avatar_url` comes with both hashes; otherwise + /// the call fails before anything is signed. An unchanged `avatar_url` may + /// be sent without hashes, which keeps the existing ones. + /// + /// `signing_key` must be a HIGH or CRITICAL authentication key of the + /// owner. Returns the merged document as confirmed by Platform. Fails if + /// the owner has no profile. pub async fn update_profile>( &self, dashpay_contract: &Arc, @@ -144,8 +204,9 @@ impl Sdk { signing_key: &IdentityPublicKey, signer: &S, ) -> Result { + fields.ensure_valid_update()?; let existing = self - .fetch_profile_documents(owner_profile_query(dashpay_contract, owner_id)) + .get_profiles(dashpay_contract, &[owner_id]) .await? .remove(&owner_id) .ok_or_else(|| { @@ -173,18 +234,24 @@ impl Sdk { .map(|document| (document.owner_id(), document)) .collect()) } -} -/// Refuse owner lists Platform would reject: more than -/// [`MAX_PROFILE_OWNERS_PER_QUERY`] owners, or a duplicated owner. -fn ensure_valid_profile_owners(owner_ids: &[Identifier]) -> Result<(), Error> { - if owner_ids.len() > MAX_PROFILE_OWNERS_PER_QUERY { - return Err(Error::Generic(format!( - "at most {MAX_PROFILE_OWNERS_PER_QUERY} profile owners per query, got {}", - owner_ids.len() - ))); + /// The profile queries and transitions are built against the contract the + /// caller passes in, so it must be the DashPay system contract. + fn ensure_dashpay_contract(&self, dashpay_contract: &DataContract) -> Result<(), Error> { + let expected = self.get_dashpay_contract_id()?; + if dashpay_contract.id() != expected { + return Err(Error::Generic(format!( + "expected the DashPay contract {expected}, got {}", + dashpay_contract.id() + ))); + } + Ok(()) } - let mut seen = std::collections::BTreeSet::new(); +} + +/// Refuse a duplicated owner, which Platform rejects in an `In` clause. +fn ensure_unique_owners(owner_ids: &[Identifier]) -> Result<(), Error> { + let mut seen = BTreeSet::new(); if let Some(duplicate) = owner_ids.iter().find(|id| !seen.insert(**id)) { return Err(Error::Generic(format!( "duplicate profile owner {duplicate} in query" @@ -193,6 +260,17 @@ fn ensure_valid_profile_owners(owner_ids: &[Identifier]) -> Result<(), Error> { Ok(()) } +/// The sanitized properties of a new profile built from `fields`. +fn new_profile_properties( + dashpay_contract: &DataContract, + fields: ProfileFields, +) -> Result, Error> { + fields.ensure_valid_create()?; + let properties = fields.overlay(BTreeMap::new()); + ensure_avatar_fields_together(&properties)?; + sanitized_properties(dashpay_contract, properties) +} + /// The replacement of `existing`: same id and owner, `fields` overlaid on its /// properties so a partial update keeps the others, revision incremented. fn replacement_document( @@ -200,10 +278,21 @@ fn replacement_document( fields: ProfileFields, dashpay_contract: &DataContract, ) -> Result { - let properties = sanitized_properties( - dashpay_contract, - fields.overlay(existing.properties().clone()), - )?; + let url_changed = fields.avatar_url.as_deref().is_some_and(|url| { + existing + .properties() + .get("avatarUrl") + .and_then(Value::as_str) + != Some(url) + }); + if url_changed && fields.avatar_hash.is_none() { + return Err(Error::Generic( + "a changed avatarUrl needs its avatarHash and avatarFingerprint".to_string(), + )); + } + let merged = fields.overlay(existing.properties().clone()); + ensure_avatar_fields_together(&merged)?; + let properties = sanitized_properties(dashpay_contract, merged)?; let revision = existing.revision().unwrap_or(INITIAL_REVISION) + 1; Ok(profile_document( existing.id(), @@ -213,6 +302,21 @@ fn replacement_document( )) } +/// The avatar URL, hash and fingerprint are all present or all absent, as the +/// schema requires; consensus would reject anything else after the broadcast. +fn ensure_avatar_fields_together(properties: &BTreeMap) -> Result<(), Error> { + let present = AVATAR_KEYS + .iter() + .filter(|key| properties.contains_key(**key)) + .count(); + if present != 0 && present != AVATAR_KEYS.len() { + return Err(Error::Generic( + "avatarUrl, avatarHash and avatarFingerprint must be set together".to_string(), + )); + } + Ok(()) +} + /// Normalize `properties` to the representations the profile schema expects. fn sanitized_properties( dashpay_contract: &DataContract, @@ -246,56 +350,23 @@ fn profile_document( /// range field or it rejects the query with "missing order by for range". The /// `$ownerId` index is unique, so ordering does not change the result set /// (at most one profile per owner). -fn profiles_query(dashpay_contract: &Arc, owner_ids: &[Identifier]) -> DocumentQuery { - DocumentQuery { - select: drive::query::SelectProjection::documents(), - data_contract: Arc::clone(dashpay_contract), - document_type_name: PROFILE_DOCUMENT_TYPE.to_string(), - where_clauses: vec![WhereClause { - field: "$ownerId".to_string(), - operator: WhereOperator::In, - value: Value::Array(owner_ids.iter().map(|id| platform_value!(id)).collect()), - }], - time_range_clauses: vec![], - integer_range_clauses: vec![], - sub_queries: vec![], - group_by: vec![], - having: vec![], - order_by_clauses: vec![OrderClause { - field: "$ownerId".to_string(), - ascending: true, - }], - limit: MAX_PROFILE_OWNERS_PER_QUERY as u32, - offset: None, - start: None, - } -} - -/// Build the single-owner `profile` query `$ownerId == owner_id` (limit 1: a -/// profile is unique per owner). -fn owner_profile_query( +fn profiles_query( dashpay_contract: &Arc, - owner_id: Identifier, -) -> DocumentQuery { - DocumentQuery { - select: drive::query::SelectProjection::documents(), - data_contract: Arc::clone(dashpay_contract), - document_type_name: PROFILE_DOCUMENT_TYPE.to_string(), - where_clauses: vec![WhereClause { - field: "$ownerId".to_string(), - operator: WhereOperator::Equal, - value: platform_value!(owner_id), - }], - time_range_clauses: vec![], - integer_range_clauses: vec![], - sub_queries: vec![], - group_by: vec![], - having: vec![], - order_by_clauses: vec![], - limit: 1, - offset: None, - start: None, - } + owner_ids: &[Identifier], +) -> Result { + Ok( + DocumentQuery::new(Arc::clone(dashpay_contract), PROFILE_DOCUMENT_TYPE)? + .with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::In, + value: Value::Array(owner_ids.iter().map(|id| platform_value!(id)).collect()), + }) + .with_order_by(OrderClause { + field: "$ownerId".to_string(), + ascending: true, + }) + .with_limit(MAX_PROFILE_OWNERS_PER_QUERY as u32), + ) } #[cfg(test)] @@ -304,13 +375,20 @@ mod tests { use dpp::system_data_contracts::load_system_data_contract; use dpp::version::PlatformVersion; - fn existing_full() -> BTreeMap { + fn avatar_fields() -> ProfileFields { ProfileFields { - display_name: Some("Alice".into()), - public_message: Some("hello world".into()), avatar_url: Some("https://x/a.png".into()), avatar_hash: Some([7u8; 32]), avatar_fingerprint: Some([1, 2, 3, 4, 5, 6, 7, 8]), + ..Default::default() + } + } + + fn existing_full() -> BTreeMap { + ProfileFields { + display_name: Some("Alice".into()), + public_message: Some("hello world".into()), + ..avatar_fields() } .overlay(BTreeMap::new()) } @@ -327,10 +405,16 @@ mod tests { .expect("DashPay system contract loads") } + fn owner(n: usize) -> Identifier { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&(n as u64 + 1).to_be_bytes()); + Identifier::new(bytes) + } + /// A partial update (only `displayName`) yields a replacement of the same /// document with the sibling fields kept and the revision incremented. #[test] - fn partial_update_replaces_the_same_document_keeping_sibling_fields() { + fn should_replace_the_same_document_keeping_sibling_fields() { let (id, owner) = (Identifier::new([0xD1; 32]), Identifier::new([0x0E; 32])); let existing = profile_document(id, owner, existing_full(), Some(3)); let fields = ProfileFields { @@ -354,42 +438,225 @@ mod tests { ); } - /// `get_profiles` refuses too many owners and duplicated owners locally. + /// A supplied avatar hash and fingerprint replace the existing ones. #[test] - fn profile_owners_over_the_cap_or_duplicated_are_rejected() { - let ids = |n: usize| -> Vec { - (0..n).map(|i| Identifier::new([i as u8 + 1; 32])).collect() - }; + fn should_replace_the_avatar_when_supplied() { + let replaced = ProfileFields { + avatar_hash: Some([9u8; 32]), + avatar_fingerprint: Some([9u8; 8]), + ..Default::default() + } + .overlay(existing_full()); + assert_eq!(replaced.get("avatarHash"), Some(&Value::Bytes32([9u8; 32]))); + assert_eq!( + replaced.get("avatarFingerprint"), + Some(&Value::Bytes(vec![9u8; 8])) + ); + } - assert!(ensure_valid_profile_owners(&[]).is_ok()); - assert!(ensure_valid_profile_owners(&ids(MAX_PROFILE_OWNERS_PER_QUERY)).is_ok()); + /// A create must not broadcast a profile the schema rejects: avatar fields + /// set partially, no field at all, or an empty text field. + #[test] + fn should_reject_an_invalid_create_before_signing() { + let contract = dashpay_contract(); + let create = |fields: ProfileFields| new_profile_properties(&contract, fields); + + assert!(create(avatar_fields()).is_ok()); + assert!(create(ProfileFields { + display_name: Some("Alice".into()), + ..Default::default() + }) + .is_ok()); + + for partial in [ + ProfileFields { + avatar_hash: None, + avatar_fingerprint: None, + ..avatar_fields() + }, + ProfileFields { + avatar_url: None, + ..avatar_fields() + }, + ProfileFields { + avatar_fingerprint: None, + ..avatar_fields() + }, + ProfileFields { + avatar_hash: Some([7u8; 32]), + ..Default::default() + }, + ] { + assert!( + matches!(create(partial.clone()), Err(Error::Generic(_))), + "{partial:?}" + ); + } assert!(matches!( - ensure_valid_profile_owners(&ids(MAX_PROFILE_OWNERS_PER_QUERY + 1)), + create(ProfileFields::default()), Err(Error::Generic(_)) )); + for empty in [ + ProfileFields { + display_name: Some(String::new()), + ..avatar_fields() + }, + ProfileFields { + public_message: Some(String::new()), + ..avatar_fields() + }, + ProfileFields { + avatar_url: Some(String::new()), + ..avatar_fields() + }, + ] { + assert!( + matches!(create(empty.clone()), Err(Error::Generic(_))), + "{empty:?}" + ); + } + } + + /// An update must not replace only one of the two hashes or set an empty + /// text field; replacing the whole avatar, an unchanged URL on its own, or + /// only unrelated fields stays allowed. + #[test] + fn should_reject_an_update_with_one_hash_or_an_empty_text_field() { + let fields_ok = |fields: ProfileFields| fields.ensure_valid_update().is_ok(); + + assert!(fields_ok(avatar_fields())); + assert!(fields_ok(ProfileFields { + avatar_url: None, + ..avatar_fields() + })); + assert!(fields_ok(ProfileFields { + avatar_hash: None, + avatar_fingerprint: None, + ..avatar_fields() + })); + assert!(fields_ok(ProfileFields { + public_message: Some("hi".into()), + ..Default::default() + })); + + assert!(!fields_ok(ProfileFields { + avatar_fingerprint: None, + ..avatar_fields() + })); + assert!(!fields_ok(ProfileFields { + avatar_hash: Some([9u8; 32]), + ..Default::default() + })); + for (name, empty) in [ + ( + "displayName", + ProfileFields { + display_name: Some(String::new()), + ..Default::default() + }, + ), + ( + "publicMessage", + ProfileFields { + public_message: Some(String::new()), + ..Default::default() + }, + ), + ( + "avatarUrl", + ProfileFields { + avatar_url: Some(String::new()), + ..Default::default() + }, + ), + ] { + assert!(!fields_ok(empty), "empty {name} accepted"); + } + } + + /// A changed avatar URL must not keep the old hashes under it, but the + /// unchanged URL the app resends on a text-only edit keeps its hashes. + #[test] + fn should_require_hashes_only_for_a_changed_avatar_url() { + let existing = profile_document( + Identifier::new([0xD1; 32]), + Identifier::new([0x0E; 32]), + existing_full(), + Some(3), + ); + let replace = + |fields: ProfileFields| replacement_document(&existing, fields, &dashpay_contract()); + let new_url = Some("https://x/b.png".to_string()); - let mut duplicated = ids(2); - duplicated.push(duplicated[0]); assert!(matches!( - ensure_valid_profile_owners(&duplicated), + replace(ProfileFields { + avatar_url: new_url.clone(), + ..Default::default() + }), Err(Error::Generic(_)) )); - } - /// A supplied avatar hash and fingerprint replace the existing ones. - #[test] - fn avatar_replaced_when_supplied() { - let replaced = ProfileFields { + let unchanged = replace(ProfileFields { + display_name: Some("Alice 2".into()), + avatar_url: Some("https://x/a.png".into()), + ..Default::default() + }) + .expect("an unchanged URL needs no hashes"); + assert_eq!( + unchanged.properties().get("avatarHash"), + Some(&Value::Bytes32([7u8; 32])) + ); + + let changed = replace(ProfileFields { + avatar_url: new_url, avatar_hash: Some([9u8; 32]), avatar_fingerprint: Some([9u8; 8]), ..Default::default() - } - .overlay(existing_full()); - assert_eq!(replaced.get("avatarHash"), Some(&Value::Bytes32([9u8; 32]))); + }) + .expect("a changed URL with its hashes"); assert_eq!( - replaced.get("avatarFingerprint"), - Some(&Value::Bytes(vec![9u8; 8])) + text(changed.properties(), "avatarUrl"), + Some("https://x/b.png") + ); + assert_eq!( + changed.properties().get("avatarHash"), + Some(&Value::Bytes32([9u8; 32])) + ); + } + + /// Hashes sent for a profile that has no avatar URL would leave a document + /// the schema rejects, so the merged result is refused. + #[test] + fn should_reject_a_replacement_whose_merged_avatar_is_incomplete() { + let existing = profile_document( + Identifier::new([0xD1; 32]), + Identifier::new([0x0E; 32]), + ProfileFields { + display_name: Some("Alice".into()), + ..Default::default() + } + .overlay(BTreeMap::new()), + Some(1), ); + let hashes_only = ProfileFields { + avatar_url: None, + ..avatar_fields() + }; + + assert!(matches!( + replacement_document(&existing, hashes_only, &dashpay_contract()), + Err(Error::Generic(_)) + )); + } + + #[test] + fn should_reject_duplicated_profile_owners() { + assert!(ensure_unique_owners(&[]).is_ok()); + assert!(ensure_unique_owners(&[owner(0), owner(1)]).is_ok()); + assert!(matches!( + ensure_unique_owners(&[owner(0), owner(1), owner(0)]), + Err(Error::Generic(_)) + )); } /// DAPI rejects any query that uses a range where-operator without a @@ -399,11 +666,10 @@ mod tests { /// Guarded against vacuous truth: the query must actually contain a range /// clause, otherwise the invariant would pass trivially. #[test] - fn profiles_query_orders_by_every_range_field() { + fn should_order_the_profile_query_by_every_range_field() { let contract = Arc::new(dashpay_contract()); - let owners = vec![Identifier::new([1u8; 32]), Identifier::new([2u8; 32])]; - let query = profiles_query(&contract, &owners); + let query = profiles_query(&contract, &[owner(0), owner(1)]).unwrap(); assert!( query.where_clauses.iter().any(|wc| wc.operator.is_range()), @@ -420,4 +686,60 @@ mod tests { } } } + + #[cfg(feature = "mocks")] + mod with_mock_sdk { + use super::*; + use crate::query_types::Documents; + use dpp::data_contract::accessors::v0::DataContractV0Setters; + + /// More than 100 owners are fetched in queries of at most 100 each and + /// merged. The mock answers only the three expected chunk queries, so a + /// single oversized query would fail. + #[tokio::test] + async fn should_fetch_profiles_in_chunks_of_at_most_one_hundred_owners() { + let contract = Arc::new(dashpay_contract()); + let owners: Vec = (0..250).map(owner).collect(); + let mut sdk = Sdk::new_mock(); + + for chunk in owners.chunks(MAX_PROFILE_OWNERS_PER_QUERY) { + let first = chunk[0]; + let document = profile_document( + Identifier::new([first.to_buffer()[7]; 32]), + first, + existing_full(), + Some(1), + ); + let answer = Documents::from([(document.id(), Some(document))]); + sdk.mock() + .expect_fetch_many::( + profiles_query(&contract, chunk).unwrap(), + Some(answer), + ) + .await + .expect("chunk expectation"); + } + + let profiles = sdk.get_profiles(&contract, &owners).await.unwrap(); + + assert_eq!( + profiles.keys().copied().collect::>(), + vec![owner(0), owner(100), owner(200)] + ); + } + + /// A contract that is not the DashPay system contract is refused before + /// any query is made. + #[tokio::test] + async fn should_reject_a_contract_that_is_not_the_dashpay_contract() { + let mut other = dashpay_contract(); + other.set_id(Identifier::new([0x42; 32])); + + let result = Sdk::new_mock() + .get_profiles(&Arc::new(other), &[owner(0)]) + .await; + + assert!(matches!(result, Err(Error::Generic(_)))); + } + } } From d1e9edc89b0750b6c768e60a34a9b19be0087f5a Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Oct 2026 06:43:14 -0500 Subject: [PATCH 14/20] refactor(platform-wallet): cache profiles sanitized and let the SDK chunk the fetch The own-profile cache written after create and update now goes through the same sanitizing parse as the sync path, so a non-https or oversized avatar URL is dropped from it too. sync_profiles and sync_contact_profiles call get_profiles once instead of looping over chunks of 100; a failed fetch now skips the whole call rather than one chunk. The SDK's owner cap constant is no longer public. Also move the inline crate:: paths in profile.rs to imports and drop the profile create/update mentions from the sdk_writer docs, which only serves the contact request and contact info writes. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/profile.rs | 241 +++++++++--------- .../src/wallet/identity/network/sdk_writer.rs | 16 +- packages/rs-sdk/src/platform/dashpay/mod.rs | 2 +- .../rs-sdk/src/platform/dashpay/profile.rs | 2 +- 4 files changed, 136 insertions(+), 125 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index 933a9b91aa1..fe89f9de002 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -2,7 +2,8 @@ use std::sync::Arc; -use dpp::document::DocumentV0Getters; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::document::{Document, DocumentV0Getters}; use dpp::identity::accessors::IdentityGettersV0; #[cfg(test)] use dpp::identity::identity_public_key::Purpose; @@ -13,12 +14,15 @@ use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::Value; use dpp::prelude::Identifier; -use dash_sdk::platform::dashpay::{ProfileFields, MAX_PROFILE_OWNERS_PER_QUERY}; +use dash_sdk::platform::dashpay::ProfileFields; use super::*; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; -use crate::wallet::identity::{ContactProfileEntry, DashPayProfile}; +use crate::wallet::identity::{ + calculate_avatar_hash, calculate_dhash_fingerprint, ContactProfileEntry, DashPayProfile, + ProfileUpdate, +}; // Profile documents require HIGH or CRITICAL authentication; MASTER is reserved // for identity operations and cannot authorize an ordinary document write. A key @@ -47,8 +51,9 @@ impl DashPayView<'_, B> { /// Fetch DashPay profile documents from Platform for all managed /// identities and cache them on [`ManagedIdentity`]. /// - /// Fetches in `In`-chunks (one query per ≤`MAX_PROFILE_OWNERS_PER_QUERY` ids, not - /// N+1) with per-chunk failure isolation, and persists only when the + /// Fetches through [`Sdk::get_profiles`](dash_sdk::Sdk::get_profiles) (`In` + /// queries, not N+1; a failed fetch leaves the cache as is for the next + /// sweep), and persists only when the /// fetched profile differs from the cached one — a `None` result clears the /// cache only when a profile is currently stored (both arms guarded so a /// no-change sweep writes nothing). Returns the number of identities whose @@ -75,29 +80,22 @@ impl DashPayView<'_, B> { // per-call re-parse, no network round-trip). let dashpay_contract = super::dashpay_contract()?; - // 3. Fetch (no guard held) in `In`-chunks. A chunk failure logs and - // continues so the other chunks still land; an id present in the - // chunk but absent from the result is confirmed-absent (`None`). - let mut fetched: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for chunk in identity_ids.chunks(MAX_PROFILE_OWNERS_PER_QUERY) { - match self - .fetch_contact_profiles_chunk(&dashpay_contract, chunk) - .await - { - Ok(found) => { - for id in chunk { - fetched.insert(*id, found.get(id).cloned().flatten()); - } - } - Err(e) => { - tracing::warn!( - error = %e, - "Failed to fetch an own-profile chunk; will retry next sweep" - ); - } + // 3. Fetch (no guard held). A failure logs and leaves the cache as is; + // an id absent from the result is confirmed-absent (`None`). + let found = match self.fetch_profiles(&dashpay_contract, &identity_ids).await { + Ok(found) => found, + Err(e) => { + tracing::warn!( + error = %e, + "Failed to fetch the own profiles; will retry next sweep" + ); + return Ok(0); } - } + }; + let fetched: std::collections::BTreeMap> = identity_ids + .iter() + .map(|id| (*id, found.get(id).cloned().flatten())) + .collect(); // 4. Under the write guard: persist-on-change only (mirror the // None-arm's is_some guard for both directions). @@ -142,14 +140,12 @@ impl DashPayView<'_, B> { pub async fn create_profile_with_external_signer( &self, identity_id: &Identifier, - input: crate::wallet::identity::ProfileUpdate, + input: ProfileUpdate, signer: &S, - ) -> Result + ) -> Result where S: Signer + Send + Sync, { - use dpp::data_contract::accessors::v0::DataContractV0Getters; - // 1. The DashPay data contract (process-wide cache). let dashpay_contract = super::dashpay_contract()?; @@ -191,18 +187,8 @@ impl DashPayView<'_, B> { ) .await .map_err(PlatformWalletError::Sdk)?; - let profile = profile_from_properties(created.properties()); - - { - let mut wm = self.wallet_manager.write().await; - if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { - if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - managed.set_dashpay_profile(Some(profile.clone()), &self.persister); - } - } - } - Ok(profile) + Ok(self.cache_own_profile(identity_id, &created).await) } /// Update an existing DashPay profile document using an @@ -213,14 +199,12 @@ impl DashPayView<'_, B> { pub async fn update_profile_with_external_signer( &self, identity_id: &Identifier, - input: crate::wallet::identity::ProfileUpdate, + input: ProfileUpdate, signer: &S, - ) -> Result + ) -> Result where S: Signer + Send + Sync, { - use dpp::data_contract::accessors::v0::DataContractV0Getters; - // 1. The DashPay contract (process-wide cache). let dashpay_contract = super::dashpay_contract()?; @@ -265,18 +249,25 @@ impl DashPayView<'_, B> { ) .await .map_err(PlatformWalletError::Sdk)?; - let profile = profile_from_properties(merged.properties()); - { - let mut wm = self.wallet_manager.write().await; - if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { - if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - managed.set_dashpay_profile(Some(profile.clone()), &self.persister); - } + Ok(self.cache_own_profile(identity_id, &merged).await) + } + + /// Cache the profile document Platform confirmed for an own identity, in + /// the same sanitized form the sync path caches, and return it. + async fn cache_own_profile( + &self, + identity_id: &Identifier, + document: &Document, + ) -> DashPayProfile { + let profile = cacheable_profile(document.properties()); + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { + managed.set_dashpay_profile(profile.clone(), &self.persister); } } - - Ok(profile) + profile.unwrap_or_default() } } @@ -286,13 +277,11 @@ impl DashPayView<'_, B> { /// The SDK profile fields for a [`ProfileUpdate`]. The avatar hash and dHash /// fingerprint are computed only when new avatar bytes are provided. -fn profile_fields( - input: crate::wallet::identity::ProfileUpdate, -) -> Result { +fn profile_fields(input: ProfileUpdate) -> Result { let (avatar_hash, avatar_fingerprint) = if let Some(bytes) = &input.avatar_bytes { - let hash = crate::wallet::identity::calculate_avatar_hash(bytes); - let fingerprint = crate::wallet::identity::calculate_dhash_fingerprint(bytes) - .map_err(PlatformWalletError::InvalidIdentityData)?; + let hash = calculate_avatar_hash(bytes); + let fingerprint = + calculate_dhash_fingerprint(bytes).map_err(PlatformWalletError::InvalidIdentityData)?; (Some(hash), Some(fingerprint)) } else { (None, None) @@ -310,9 +299,7 @@ fn profile_fields( /// Empty strings are normalized to `None`. `avatarHash`/`avatarFingerprint` /// are read via `as_bytes_slice` so both `Bytes` and the sized `Bytes32` /// representation round-trip. -fn profile_from_properties( - props: &std::collections::BTreeMap, -) -> crate::wallet::identity::DashPayProfile { +fn profile_from_properties(props: &std::collections::BTreeMap) -> DashPayProfile { let text = |key: &str| { props .get(key) @@ -330,7 +317,7 @@ fn profile_from_properties( .and_then(|v: &Value| v.as_bytes_slice().ok()) .and_then(|bytes| <[u8; 8]>::try_from(bytes).ok()); - crate::wallet::identity::DashPayProfile { + DashPayProfile { display_name: text("displayName"), bio: public_message.clone(), avatar_url: text("avatarUrl"), @@ -401,7 +388,7 @@ impl DashPayView<'_, B> { /// `updateContactProfiles`: iterate the full contact set every sweep /// (so a contact established before this shipped is backfilled, and a /// dropped fetch self-heals next sweep), skip recently-checked ids, - /// fetch in `In`-chunks with per-chunk failure isolation, and write the + /// fetch per owner with failure isolation, and write the /// per-owner cache with full-replace + persist-on-change. Contacts that /// are themselves managed identities are skipped (their own /// `dashpay_profile` is authoritative). Display-only: a failure never @@ -468,38 +455,30 @@ impl DashPayView<'_, B> { return Ok(0); } - // 2. Fetch (no guard held). Per chunk: one `In` query over ≤IN_CAP - // owner ids; a chunk failure logs and continues so the others - // still land. An id present in the chunk but absent from the - // result is confirmed-absent (cached as `None` — the negative - // cache). + // 2. Fetch (no guard held). Per owner: one `get_profiles` call; a + // failure logs and continues so the other owners still land. An id + // requested but absent from the result is confirmed-absent (cached + // as `None` — the negative cache). // One owner's fetched contacts: each contact id paired with its profile, or // `None` when confirmed-absent (the negative cache). type OwnerContactProfiles = Vec<(Identifier, Option)>; let mut results: Vec<(Identifier, OwnerContactProfiles)> = Vec::new(); for (owner_id, to_fetch) in plan { - let mut owner_results: OwnerContactProfiles = Vec::new(); - for chunk in to_fetch.chunks(MAX_PROFILE_OWNERS_PER_QUERY) { - match self - .fetch_contact_profiles_chunk(&dashpay_contract, chunk) - .await - { - Ok(found) => { - for id in chunk { - owner_results.push((*id, found.get(id).cloned().flatten())); - } - } - Err(e) => { - tracing::warn!( - owner = %owner_id, - error = %e, - "Failed to fetch a contact-profile chunk; will retry next sweep" - ); - } + match self.fetch_profiles(&dashpay_contract, &to_fetch).await { + Ok(found) => { + let owner_results: OwnerContactProfiles = to_fetch + .iter() + .map(|id| (*id, found.get(id).cloned().flatten())) + .collect(); + results.push((owner_id, owner_results)); + } + Err(e) => { + tracing::warn!( + owner = %owner_id, + error = %e, + "Failed to fetch the contact profiles; will retry next sweep" + ); } - } - if !owner_results.is_empty() { - results.push((owner_id, owner_results)); } } @@ -547,41 +526,44 @@ impl DashPayView<'_, B> { Ok(written) } - /// Fetch one chunk of profiles through [`Sdk::get_profiles`](dash_sdk::Sdk::get_profiles), + /// Fetch profiles through [`Sdk::get_profiles`](dash_sdk::Sdk::get_profiles), /// returning the present profiles keyed by owner id (absent ids are simply /// missing). The `profile` `ownerId` index is unique, so the set lookup /// needs no pagination (≤1 profile per owner). - async fn fetch_contact_profiles_chunk( + async fn fetch_profiles( &self, dashpay_contract: &Arc, - chunk: &[Identifier], + owner_ids: &[Identifier], ) -> Result>, PlatformWalletError> { let docs = self .sdk - .get_profiles(dashpay_contract, chunk) + .get_profiles(dashpay_contract, owner_ids) .await .map_err(PlatformWalletError::Sdk)?; - let mut out = std::collections::BTreeMap::new(); - for (owner, doc) in docs { - let mut profile = profile_from_properties(doc.properties()); - // Drop an untrusted avatar URL rather than caching it. - if profile - .avatar_url - .as_deref() - .is_some_and(|u| !is_valid_avatar_url(u)) - { - profile.avatar_url = None; - } - // A doc that parses to no populated field is treated as - // confirmed-absent (negative cache), not a cached-present empty - // profile — so self-heal keeps it honest. - let entry = (profile != DashPayProfile::default()).then_some(profile); - out.insert(owner, entry); - } - Ok(out) + Ok(docs + .into_iter() + .map(|(owner, doc)| (owner, cacheable_profile(doc.properties()))) + .collect()) + } +} + +/// Parse a profile document into the form the cache stores, for every path +/// that caches one. An untrusted avatar URL is dropped rather than cached. A +/// doc that parses to no populated field is `None` (confirmed-absent, the +/// negative cache), not a cached-present empty profile — so self-heal keeps +/// it honest. +fn cacheable_profile(props: &std::collections::BTreeMap) -> Option { + let mut profile = profile_from_properties(props); + if profile + .avatar_url + .as_deref() + .is_some_and(|u| !is_valid_avatar_url(u)) + { + profile.avatar_url = None; } + (profile != DashPayProfile::default()).then_some(profile) } #[cfg(test)] @@ -690,7 +672,7 @@ mod tests { /// `publicMessage` is both the bio and the public message, and an /// `avatarHash` stored as the sized `Bytes32` round-trips. #[test] - fn profile_from_properties_reads_every_field() { + fn should_read_every_profile_field_from_properties() { let mut props = BTreeMap::new(); props.insert("displayName".to_string(), Value::Text("Alice".into())); props.insert("publicMessage".to_string(), Value::Text("hello".into())); @@ -713,6 +695,35 @@ mod tests { assert_eq!(prof.avatar_fingerprint, Some([1, 2, 3, 4, 5, 6, 7, 8])); } + /// Every cached profile, own or contact, is sanitized the same way: a + /// non-https avatar URL is dropped while the other fields stay, and a + /// document with no populated field is not cached as a profile. + #[test] + fn should_cache_a_profile_without_an_untrusted_avatar_url() { + let mut props = BTreeMap::new(); + props.insert("displayName".to_string(), Value::Text("Alice".into())); + props.insert( + "avatarUrl".to_string(), + Value::Text("http://example.com/a.png".into()), + ); + + let cached = cacheable_profile(&props).expect("display name keeps it present"); + assert_eq!(cached.display_name.as_deref(), Some("Alice")); + assert_eq!(cached.avatar_url, None); + + props.insert( + "avatarUrl".to_string(), + Value::Text("https://example.com/a.png".into()), + ); + let cached = cacheable_profile(&props).unwrap(); + assert_eq!( + cached.avatar_url.as_deref(), + Some("https://example.com/a.png") + ); + + assert_eq!(cacheable_profile(&BTreeMap::new()), None); + } + // --- contact-profile sync helpers --- /// Only bounded `https://` avatar URLs are cached — `http:`, scheme diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs b/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs index eefad5dcd29..5012ea38ff2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs @@ -12,8 +12,8 @@ //! `&dyn Signer`. //! //! `IdentityWallet` keeps doing all of the derivation (key-index -//! resolution, ECDH-key derivation, xpub derivation, avatar hashing, -//! document construction); this helper receives the already-derived +//! resolution, ECDH-key derivation, xpub derivation, document +//! construction); this helper receives the already-derived //! primitives and performs the final SDK call. use std::sync::Arc; @@ -31,8 +31,8 @@ use dash_sdk::platform::dashpay::{ use crate::error::PlatformWalletError; -// Borrowed-signer adapter — same pattern used by `contact_requests.rs` -// / `profile.rs`. Lets a `&dyn Signer` satisfy the +// Borrowed-signer adapter — same pattern used by `contact_requests.rs`. +// Lets a `&dyn Signer` satisfy the // owned, `Sized` `S: Signer` bound the SDK input // types require, so the helper methods below can take a borrowed signer // while still threading the host's signer to the SDK. @@ -129,7 +129,7 @@ pub(crate) struct SendContactRequestParams<'a> { /// Pre-built inputs for a single DashPay document put. /// -/// `IdentityWallet` builds the [`Document`] (profile create/update) and +/// `IdentityWallet` builds the [`Document`] (contact info) and /// resolves the signing key + document type; the helper performs the /// `put_to_platform_and_wait_for_response` broadcast. pub(crate) struct PutDocumentParams<'a> { @@ -149,12 +149,12 @@ pub(crate) struct PutDocumentParams<'a> { /// forwarding to the real SDK. Its two methods erase the SDK's /// generic write signatures (`send_contact_request`'s seven type /// params, the signer-generic `PutDocument`) behind concrete, by-value -/// inputs so the call sites in `contact_requests.rs` / `profile.rs` / +/// inputs so the call sites in `contact_requests.rs` / /// `contact_info.rs` stay simple. /// /// The methods' returned futures are `Send`: the write paths this -/// helper serves (`send_contact_request_with_external_signer`, profile -/// create/update) are driven through the FFI's `block_on_worker`, which +/// helper serves (`send_contact_request_with_external_signer`, the +/// contact-info put) are driven through the FFI's `block_on_worker`, which /// requires `Future: Send`. (The DashPay *read*/sync path, which is /// `!Send` and runs on a dedicated thread, does not go through this /// helper.) diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 57362969519..881f1b2b88a 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -13,7 +13,7 @@ pub use contact_request::{ EcdhProvider, RecipientIdentity, SendContactRequestInput, SendContactRequestResult, }; pub use contact_request_queries::ContactRequestDocuments; -pub use profile::{ProfileFields, MAX_PROFILE_OWNERS_PER_QUERY}; +pub use profile::ProfileFields; use crate::{Error, Sdk}; use dpp::prelude::Identifier; diff --git a/packages/rs-sdk/src/platform/dashpay/profile.rs b/packages/rs-sdk/src/platform/dashpay/profile.rs index 1148aab8ef6..045697d1e4a 100644 --- a/packages/rs-sdk/src/platform/dashpay/profile.rs +++ b/packages/rs-sdk/src/platform/dashpay/profile.rs @@ -32,7 +32,7 @@ const AVATAR_KEYS: [&str; 3] = ["avatarUrl", "avatarHash", "avatarFingerprint"]; /// Most owners one profile query can carry: the Platform `In` clause /// cardinality cap, which is also the document-query limit. -pub const MAX_PROFILE_OWNERS_PER_QUERY: usize = 100; +pub(crate) const MAX_PROFILE_OWNERS_PER_QUERY: usize = 100; /// The profile fields a caller sets. `None` leaves the field alone on /// [`Sdk::update_profile`] and omits it on [`Sdk::create_profile`]. From 25283eec9ae55c6991e5f0d12d27db652de3b2aa Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Oct 2026 06:48:59 -0500 Subject: [PATCH 15/20] fix(sdk): adapt dash-sdk-cxx to the profile review round get_profiles now chunks internally, so the 100-owner cap in the bridge doc comment no longer holds. It also refuses a duplicated owner before any query. MAX_PROFILE_OWNERS_PER_QUERY is pub(crate) and was never used here. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk-cxx/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk-cxx/src/lib.rs b/packages/rs-sdk-cxx/src/lib.rs index 459352ed872..562ac3c6b57 100644 --- a/packages/rs-sdk-cxx/src/lib.rs +++ b/packages/rs-sdk-cxx/src/lib.rs @@ -475,8 +475,9 @@ pub mod ffi { /// the next page. Platform records votes by proTxHash, not by voter /// identity. Hash in the embedder's internal byte order. fn identity_votes(self: &PlatformClient, pro_tx_hash: &[u8; 32]) -> NameVotesResult; - /// The DashPay profiles of `owners` (unique, at most 100); an owner - /// without one is missing from the result. + /// The DashPay profiles of `owners` (unique, any number); an owner + /// without one is missing from the result. A duplicated owner is + /// refused before any query. fn get_profiles(self: &PlatformClient, owners: &[[u8; 32]]) -> ProfilesResult; /// Every request sent by or to `identity`, created after `since_ms` /// (0 = all). From f8632c0f6543a4c57c4515f8b9137afa26916d24 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Oct 2026 08:07:15 -0500 Subject: [PATCH 16/20] fix(sdk): leave DashPay profile schema rules to Platform validation create_profile and update_profile repeated the profile schema's minLength (empty text fields), minProperties (an all-None create) and dependentRequired (avatar URL, hash and fingerprint together) rules by hand, which the "Validation lives once" convention forbids. Remove them; a document that breaks the schema is rejected by Platform's validation, as it was before these checks were added. Keep the two update rules the schema cannot express, because on the merged document the stored avatar fills the gap: one avatar hash without the other, and a changed avatar_url without both hashes, either of which would leave a hash of a different image under the avatar. Both are now checked in replacement_document, so the tested function holds them. An unchanged avatar_url sent without hashes is still accepted and keeps the stored hashes. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk/src/platform/dashpay/profile.rs | 251 ++++-------------- 1 file changed, 46 insertions(+), 205 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/profile.rs b/packages/rs-sdk/src/platform/dashpay/profile.rs index 045697d1e4a..fce6d5ae2fa 100644 --- a/packages/rs-sdk/src/platform/dashpay/profile.rs +++ b/packages/rs-sdk/src/platform/dashpay/profile.rs @@ -27,9 +27,6 @@ use std::sync::Arc; const PROFILE_DOCUMENT_TYPE: &str = "profile"; -/// The profile schema requires these three together (`dependentRequired`). -const AVATAR_KEYS: [&str; 3] = ["avatarUrl", "avatarHash", "avatarFingerprint"]; - /// Most owners one profile query can carry: the Platform `In` clause /// cardinality cap, which is also the document-query limit. pub(crate) const MAX_PROFILE_OWNERS_PER_QUERY: usize = 100; @@ -38,10 +35,9 @@ pub(crate) const MAX_PROFILE_OWNERS_PER_QUERY: usize = 100; /// [`Sdk::update_profile`] and omits it on [`Sdk::create_profile`]. /// /// `avatar_hash` and `avatar_fingerprint` are computed by the caller from the -/// avatar image bytes. The schema requires the three avatar fields together: -/// a create sets all three or none, and an update that changes `avatar_url` -/// must set both hashes with it. The two hashes are always set together, and a -/// text field that is set is not empty. A field cannot be cleared. +/// avatar image bytes. An update that changes `avatar_url` must set both hashes +/// with it, and an update sets the two hashes together. A field cannot be +/// cleared. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ProfileFields { /// Display name (1 to 25 chars per DIP-15) @@ -80,46 +76,6 @@ impl ProfileFields { } properties } - - /// Refuse, before anything is signed, a create the schema would reject - /// after the broadcast: no field at all, or an empty text field. - fn ensure_valid_create(&self) -> Result<(), Error> { - if *self == ProfileFields::default() { - return Err(Error::Generic( - "a profile needs at least one field".to_string(), - )); - } - self.ensure_text_fields_not_empty() - } - - /// Refuse, before anything is read or signed, an update that replaces only - /// one of the two avatar hashes, or sets an empty text field. A changed - /// `avatar_url` needs its hashes too; that is checked against the existing - /// document in [`replacement_document`]. - fn ensure_valid_update(&self) -> Result<(), Error> { - if self.avatar_hash.is_some() != self.avatar_fingerprint.is_some() { - return Err(Error::Generic( - "avatarHash and avatarFingerprint must be set together".to_string(), - )); - } - self.ensure_text_fields_not_empty() - } - - /// Every profile text field has `minLength` 1. - fn ensure_text_fields_not_empty(&self) -> Result<(), Error> { - let text_fields = [ - ("displayName", &self.display_name), - ("publicMessage", &self.public_message), - ("avatarUrl", &self.avatar_url), - ]; - match text_fields - .into_iter() - .find(|(_, value)| value.as_deref() == Some("")) - { - Some((name, _)) => Err(Error::Generic(format!("{name} must not be empty"))), - None => Ok(()), - } - } } impl Sdk { @@ -148,10 +104,6 @@ impl Sdk { /// Create the profile document of `owner_id` from `fields`. /// - /// `fields` must set at least one field, no empty text field, and all of - /// the avatar fields or none; otherwise the call fails before anything is - /// signed. - /// /// `signing_key` must be a HIGH or CRITICAL authentication key of the /// owner. Returns the document as confirmed by Platform. pub async fn create_profile>( @@ -163,7 +115,7 @@ impl Sdk { signer: &S, ) -> Result { self.ensure_dashpay_contract(dashpay_contract)?; - let properties = new_profile_properties(dashpay_contract, fields)?; + let properties = sanitized_properties(dashpay_contract, fields.overlay(BTreeMap::new()))?; let entropy: [u8; 32] = StdRng::from_os_rng().random(); let id = Document::generate_document_id_v0( @@ -188,10 +140,10 @@ impl Sdk { /// `fields` that are set so a partial update keeps the other fields, /// increment the revision and replace it. /// - /// Text fields that are set must not be empty, the two avatar hashes are set - /// together, and a changed `avatar_url` comes with both hashes; otherwise - /// the call fails before anything is signed. An unchanged `avatar_url` may - /// be sent without hashes, which keeps the existing ones. + /// The two avatar hashes are set together, and a changed `avatar_url` comes + /// with both hashes; otherwise the call fails before anything is signed. An + /// unchanged `avatar_url` may be sent without hashes, which keeps the + /// existing ones. /// /// `signing_key` must be a HIGH or CRITICAL authentication key of the /// owner. Returns the merged document as confirmed by Platform. Fails if @@ -204,7 +156,6 @@ impl Sdk { signing_key: &IdentityPublicKey, signer: &S, ) -> Result { - fields.ensure_valid_update()?; let existing = self .get_profiles(dashpay_contract, &[owner_id]) .await? @@ -260,24 +211,23 @@ fn ensure_unique_owners(owner_ids: &[Identifier]) -> Result<(), Error> { Ok(()) } -/// The sanitized properties of a new profile built from `fields`. -fn new_profile_properties( - dashpay_contract: &DataContract, - fields: ProfileFields, -) -> Result, Error> { - fields.ensure_valid_create()?; - let properties = fields.overlay(BTreeMap::new()); - ensure_avatar_fields_together(&properties)?; - sanitized_properties(dashpay_contract, properties) -} - /// The replacement of `existing`: same id and owner, `fields` overlaid on its /// properties so a partial update keeps the others, revision incremented. +/// +/// Refuses two patches the schema cannot see on the merged document, because +/// the stored avatar fills the gap, and that would leave a hash of a different +/// image under the avatar: one of the two hashes alone, and a changed +/// `avatar_url` without both. fn replacement_document( existing: &Document, fields: ProfileFields, dashpay_contract: &DataContract, ) -> Result { + if fields.avatar_hash.is_some() != fields.avatar_fingerprint.is_some() { + return Err(Error::Generic( + "avatarHash and avatarFingerprint must be set together".to_string(), + )); + } let url_changed = fields.avatar_url.as_deref().is_some_and(|url| { existing .properties() @@ -290,9 +240,10 @@ fn replacement_document( "a changed avatarUrl needs its avatarHash and avatarFingerprint".to_string(), )); } - let merged = fields.overlay(existing.properties().clone()); - ensure_avatar_fields_together(&merged)?; - let properties = sanitized_properties(dashpay_contract, merged)?; + let properties = sanitized_properties( + dashpay_contract, + fields.overlay(existing.properties().clone()), + )?; let revision = existing.revision().unwrap_or(INITIAL_REVISION) + 1; Ok(profile_document( existing.id(), @@ -302,21 +253,6 @@ fn replacement_document( )) } -/// The avatar URL, hash and fingerprint are all present or all absent, as the -/// schema requires; consensus would reject anything else after the broadcast. -fn ensure_avatar_fields_together(properties: &BTreeMap) -> Result<(), Error> { - let present = AVATAR_KEYS - .iter() - .filter(|key| properties.contains_key(**key)) - .count(); - if present != 0 && present != AVATAR_KEYS.len() { - return Err(Error::Generic( - "avatarUrl, avatarHash and avatarFingerprint must be set together".to_string(), - )); - } - Ok(()) -} - /// Normalize `properties` to the representations the profile schema expects. fn sanitized_properties( dashpay_contract: &DataContract, @@ -454,126 +390,56 @@ mod tests { ); } - /// A create must not broadcast a profile the schema rejects: avatar fields - /// set partially, no field at all, or an empty text field. + /// An update must not replace only one of the two hashes, though the + /// stored avatar keeps the merged document complete; replacing the whole + /// avatar, an unchanged URL on its own, or only unrelated fields stays + /// allowed. #[test] - fn should_reject_an_invalid_create_before_signing() { - let contract = dashpay_contract(); - let create = |fields: ProfileFields| new_profile_properties(&contract, fields); - - assert!(create(avatar_fields()).is_ok()); - assert!(create(ProfileFields { - display_name: Some("Alice".into()), - ..Default::default() - }) - .is_ok()); + fn should_reject_an_update_with_one_hash() { + let existing = profile_document( + Identifier::new([0xD1; 32]), + Identifier::new([0x0E; 32]), + existing_full(), + Some(3), + ); + let replace = + |fields: ProfileFields| replacement_document(&existing, fields, &dashpay_contract()); - for partial in [ - ProfileFields { - avatar_hash: None, - avatar_fingerprint: None, - ..avatar_fields() - }, + for allowed in [ + avatar_fields(), ProfileFields { avatar_url: None, ..avatar_fields() }, ProfileFields { + avatar_hash: None, avatar_fingerprint: None, ..avatar_fields() }, ProfileFields { - avatar_hash: Some([7u8; 32]), + public_message: Some("hi".into()), ..Default::default() }, ] { - assert!( - matches!(create(partial.clone()), Err(Error::Generic(_))), - "{partial:?}" - ); + assert!(replace(allowed.clone()).is_ok(), "{allowed:?}"); } - assert!(matches!( - create(ProfileFields::default()), - Err(Error::Generic(_)) - )); - for empty in [ + for one_hash in [ ProfileFields { - display_name: Some(String::new()), - ..avatar_fields() - }, - ProfileFields { - public_message: Some(String::new()), + avatar_fingerprint: None, ..avatar_fields() }, ProfileFields { - avatar_url: Some(String::new()), - ..avatar_fields() + avatar_hash: Some([9u8; 32]), + ..Default::default() }, ] { assert!( - matches!(create(empty.clone()), Err(Error::Generic(_))), - "{empty:?}" + matches!(replace(one_hash.clone()), Err(Error::Generic(_))), + "{one_hash:?}" ); } } - /// An update must not replace only one of the two hashes or set an empty - /// text field; replacing the whole avatar, an unchanged URL on its own, or - /// only unrelated fields stays allowed. - #[test] - fn should_reject_an_update_with_one_hash_or_an_empty_text_field() { - let fields_ok = |fields: ProfileFields| fields.ensure_valid_update().is_ok(); - - assert!(fields_ok(avatar_fields())); - assert!(fields_ok(ProfileFields { - avatar_url: None, - ..avatar_fields() - })); - assert!(fields_ok(ProfileFields { - avatar_hash: None, - avatar_fingerprint: None, - ..avatar_fields() - })); - assert!(fields_ok(ProfileFields { - public_message: Some("hi".into()), - ..Default::default() - })); - - assert!(!fields_ok(ProfileFields { - avatar_fingerprint: None, - ..avatar_fields() - })); - assert!(!fields_ok(ProfileFields { - avatar_hash: Some([9u8; 32]), - ..Default::default() - })); - for (name, empty) in [ - ( - "displayName", - ProfileFields { - display_name: Some(String::new()), - ..Default::default() - }, - ), - ( - "publicMessage", - ProfileFields { - public_message: Some(String::new()), - ..Default::default() - }, - ), - ( - "avatarUrl", - ProfileFields { - avatar_url: Some(String::new()), - ..Default::default() - }, - ), - ] { - assert!(!fields_ok(empty), "empty {name} accepted"); - } - } - /// A changed avatar URL must not keep the old hashes under it, but the /// unchanged URL the app resends on a text-only edit keeps its hashes. #[test] @@ -624,31 +490,6 @@ mod tests { ); } - /// Hashes sent for a profile that has no avatar URL would leave a document - /// the schema rejects, so the merged result is refused. - #[test] - fn should_reject_a_replacement_whose_merged_avatar_is_incomplete() { - let existing = profile_document( - Identifier::new([0xD1; 32]), - Identifier::new([0x0E; 32]), - ProfileFields { - display_name: Some("Alice".into()), - ..Default::default() - } - .overlay(BTreeMap::new()), - Some(1), - ); - let hashes_only = ProfileFields { - avatar_url: None, - ..avatar_fields() - }; - - assert!(matches!( - replacement_document(&existing, hashes_only, &dashpay_contract()), - Err(Error::Generic(_)) - )); - } - #[test] fn should_reject_duplicated_profile_owners() { assert!(ensure_unique_owners(&[]).is_ok()); From 911ac9f5aa2aba5f41f1f404efad2bae7f856651 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 19 Oct 2026 07:46:13 -0500 Subject: [PATCH 17/20] fix(sdk): accept the invitation applink only over https The applink carries the plaintext voucher WIF, so it is a bearer credential and must not be accepted over cleartext http. Both mobile wallets build only https://invitations.dashpay.io/applink links. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk/src/platform/dashpay/invitation.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/rs-sdk/src/platform/dashpay/invitation.rs b/packages/rs-sdk/src/platform/dashpay/invitation.rs index 785b7bfae9e..d5e6d664115 100644 --- a/packages/rs-sdk/src/platform/dashpay/invitation.rs +++ b/packages/rs-sdk/src/platform/dashpay/invitation.rs @@ -508,7 +508,7 @@ pub fn parse_invitation_uri(uri: &str) -> Result { parts.host == INVITATION_SCHEME_HOST && (parts.path.is_empty() || parts.path == "/") } - "https" | "http" => { + "https" => { parts.host == INVITATION_APPLINK_HOST && parts.path.trim_end_matches('/') == INVITATION_APPLINK_PATH } @@ -1431,6 +1431,19 @@ mod tests { assert_eq!(parsed.funding_txid, "aabb"); } + /// The link carries the plaintext voucher WIF, so a bearer link must not be + /// accepted over cleartext `http`. Both mobile wallets emit only `https`. + #[test] + fn should_reject_an_applink_over_plain_http() { + let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); + let uri = + format!("http://invitations.dashpay.io/applink?du=carol&assetlocktx=aabb&pk={wif}"); + assert!(matches!( + parse_invitation_uri(&uri), + Err(InvitationError::Invalid(_)) + )); + } + /// `islock` present / absent / `"null"`: only a real hex value yields /// `Some`; both a missing param and the literal `"null"` yield `None` /// (a ChainLock-confirmed invite Android's own validator accepts). From 35decc70e3a96b6194c5fc0d13929f5db2d80fb4 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 19 Oct 2026 07:46:13 -0500 Subject: [PATCH 18/20] fix(sdk): compare every creation field when adopting a consumed invitation's identity After an outpoint-consumed rejection the claim adopted an identity whose key ids, key data and key types matched. Compare each whole key, ignoring only disabled_at, so a differing purpose, security level, read-only flag, contract bounds or V1 usage limits is not mistaken for our identity. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk/src/platform/dashpay/invitation.rs | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/invitation.rs b/packages/rs-sdk/src/platform/dashpay/invitation.rs index d5e6d664115..ced6dcd9ff1 100644 --- a/packages/rs-sdk/src/platform/dashpay/invitation.rs +++ b/packages/rs-sdk/src/platform/dashpay/invitation.rs @@ -73,7 +73,9 @@ use dpp::dashcore::secp256k1::{PublicKey, SecretKey}; use dpp::dashcore::transaction::special_transaction::TransactionPayload; use dpp::dashcore::{InstantLock, Network, OutPoint, PrivateKey, ScriptBuf, Transaction, TxOut}; use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; use dpp::identity::signer::Signer; use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; @@ -1123,19 +1125,26 @@ where } } -/// The identity carries exactly `expected` public keys — same ids, same key -/// data. Key data is what only our signer could have produced, so a match -/// means the identity was created by our claim, not by a finder of the link. +/// The identity carries exactly `expected` public keys — every field of each +/// key equal, other than `disabled_at`, which is not part of identity creation. +/// Key data is what only our signer could have produced, so a match means the +/// identity was created by our claim, not by a finder of the link; the other +/// fields (purpose, security level, bounds, limits) are what we asked for. fn identity_carries_keys( identity: &Identity, expected: &BTreeMap, ) -> bool { + let creation_fields = |key: &IdentityPublicKey| { + let mut key = key.clone(); + key.remove_disabled_at(); + key + }; let actual = identity.public_keys(); actual.len() == expected.len() && expected.iter().all(|(id, key)| { - actual.get(id).is_some_and(|found| { - found.data() == key.data() && found.key_type() == key.key_type() - }) + actual + .get(id) + .is_some_and(|found| creation_fields(found) == creation_fields(key)) }) } @@ -1923,6 +1932,35 @@ mod tests { assert!(!identity_carries_keys(&identity_with_keys(fewer), &ours)); } + /// Matching key data is not enough: a key that differs from what we + /// submitted in purpose, security level, read-only flag or usage limits is + /// not our identity. `disabled_at` is not part of creation and is ignored. + #[test] + fn should_not_adopt_an_identity_whose_keys_differ_beyond_their_data() { + let ours = our_keys(); + let with_key = |key: IdentityPublicKey| { + let mut keys = ours.clone(); + keys.insert(1, key); + identity_with_keys(keys) + }; + let base = test_key(1, 2); + + let mut other_purpose = base.clone(); + other_purpose.set_purpose(Purpose::ENCRYPTION); + let mut other_level = base.clone(); + other_level.set_security_level(SecurityLevel::CRITICAL); + let mut read_only = base.clone(); + read_only.set_read_only(true); + let limited = base.clone().with_limits(Some(1_000), None); + for differing in [other_purpose, other_level, read_only, limited] { + assert!(!identity_carries_keys(&with_key(differing), &ours)); + } + + let mut disabled = base; + disabled.set_disabled_at(1); + assert!(identity_carries_keys(&with_key(disabled), &ours)); + } + /// A testnet link in a mainnet wallet is refused with the typed /// `InvitationError::NetworkMismatch`. #[test] From 4d94c849fe2c0f0b9d4ca000dcfd1753eedeb36c Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 19 Oct 2026 07:56:17 -0500 Subject: [PATCH 19/20] test(sdk): isolate the key-limit case in the consumed-invitation adoption test Compare two V1 keys that differ only in total_budget, so the case cannot pass on a variant difference alone. Note in the doc that a limit raised after creation reads as a mismatch. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk/src/platform/dashpay/invitation.rs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/invitation.rs b/packages/rs-sdk/src/platform/dashpay/invitation.rs index ced6dcd9ff1..ac00338f5a9 100644 --- a/packages/rs-sdk/src/platform/dashpay/invitation.rs +++ b/packages/rs-sdk/src/platform/dashpay/invitation.rs @@ -1127,6 +1127,8 @@ where /// The identity carries exactly `expected` public keys — every field of each /// key equal, other than `disabled_at`, which is not part of identity creation. +/// A key limit the owner raised after creation also reads as a mismatch, which +/// reports the original rejection rather than adopting. /// Key data is what only our signer could have produced, so a match means the /// identity was created by our claim, not by a finder of the link; the other /// fields (purpose, security level, bounds, limits) are what we asked for. @@ -1441,7 +1443,7 @@ mod tests { } /// The link carries the plaintext voucher WIF, so a bearer link must not be - /// accepted over cleartext `http`. Both mobile wallets emit only `https`. + /// accepted over cleartext `http`. Neither mobile wallet emits an `http` link. #[test] fn should_reject_an_applink_over_plain_http() { let wif = PrivateKey::new(voucher(), Network::Testnet).to_wif(); @@ -1951,11 +1953,25 @@ mod tests { other_level.set_security_level(SecurityLevel::CRITICAL); let mut read_only = base.clone(); read_only.set_read_only(true); - let limited = base.clone().with_limits(Some(1_000), None); - for differing in [other_purpose, other_level, read_only, limited] { + for differing in [other_purpose, other_level, read_only] { assert!(!identity_carries_keys(&with_key(differing), &ours)); } + // Usage limits exist only on V1 keys: with ours V1 too, only the budget differs. + let mut ours_v1 = ours.clone(); + ours_v1.insert(1, base.clone().with_limits(None, None)); + let budgeted = base.clone().with_limits(Some(1_000), None); + assert!(identity_carries_keys( + &identity_with_keys(ours_v1.clone()), + &ours_v1 + )); + let mut theirs = ours_v1.clone(); + theirs.insert(1, budgeted); + assert!(!identity_carries_keys( + &identity_with_keys(theirs), + &ours_v1 + )); + let mut disabled = base; disabled.set_disabled_at(1); assert!(identity_carries_keys(&with_key(disabled), &ours)); From 69b67ff19b6ed7dc699a6709c20f9061312261f3 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 19 Oct 2026 10:04:13 -0500 Subject: [PATCH 20/20] fix(sdk): wipe the temporary voucher key copy after WIF encoding PrivateKey is Copy and not zeroized on drop, so the copy made to format the invitation link's WIF stayed in memory after encode_invitation_uri returned. Erase it as the parser and claim paths already do. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/src/platform/dashpay/invitation.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/rs-sdk/src/platform/dashpay/invitation.rs b/packages/rs-sdk/src/platform/dashpay/invitation.rs index ac00338f5a9..d15884a8e9c 100644 --- a/packages/rs-sdk/src/platform/dashpay/invitation.rs +++ b/packages/rs-sdk/src/platform/dashpay/invitation.rs @@ -423,7 +423,12 @@ pub fn encode_invitation_uri( }; // WIF, compressed (the default for `PrivateKey::new`), network-correct. - let wif = PrivateKey::new(*voucher_key, network).to_wif(); + // `PrivateKey` is `Copy` and not zeroized on drop, so wipe this copy. + let mut private_key = PrivateKey::new(*voucher_key, network); + let mut wif = String::with_capacity(52); + let wif_result = private_key.fmt_wif(&mut wif); + private_key.inner.non_secure_erase(); + wif_result.map_err(|_| invalid("failed to encode invitation voucher key"))?; let mut query = String::new(); let mut push = |key: &str, val: &str| {