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 fe3988d872c..b03c3b18e98 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: | @@ -462,6 +486,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 \ diff --git a/Cargo.lock b/Cargo.lock index 9d36bc8fc09..f7399d3c33d 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", ] @@ -1349,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" @@ -1707,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-rc.4" @@ -1848,7 +1874,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 +1887,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", @@ -1967,6 +1993,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dash-sdk-cxx" +version = "5.0.0-rc.4" +dependencies = [ + "async-trait", + "cxx", + "cxx-build", + "dash-sdk", + "platform-encryption", + "tokio", + "zeroize", +] + [[package]] name = "dash-spv" version = "0.45.0" @@ -2053,7 +2092,7 @@ name = "dashcore-crypto" version = "0.45.0" source = "git+https://github.com/dashpay/rust-dashcore?rev=626c91d1efcc471d2ea06290cadd08ef222aee35#626c91d1efcc471d2ea06290cadd08ef222aee35" dependencies = [ - "base58ck 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "base58ck", "bitcoin-crypto", "dash-network", "dash-pkc", @@ -3718,15 +3757,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" @@ -4606,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" @@ -7081,6 +7120,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" @@ -7841,6 +7886,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" @@ -8663,6 +8717,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 3d099887b8e..f90d05a8496 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", @@ -75,6 +76,17 @@ dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "626c91 dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "626c91d1efcc471d2ea06290cadd08ef222aee35" } 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`) 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/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 2810606f8c0..bb5e83480f3 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -119,8 +119,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 85d969fbe29..5696d681d2b 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1,4 +1,5 @@ use dash_sdk::error::ContextProviderError; +use dash_sdk::platform::dashpay::InvitationError; use dashcore::sml::llmq_entry_verification::LLMQEntryVerificationStatus; use dpp::address_funds::PlatformAddress; use dpp::consensus::state::address_funds::AddressInvalidNonceError; @@ -11,6 +12,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 { @@ -1033,6 +1039,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. /// @@ -1108,7 +1130,7 @@ 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, } @@ -1136,23 +1158,6 @@ impl PlatformWalletError { } } -/// 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 @@ -1161,7 +1166,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(_), )) @@ -1185,61 +1190,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 @@ -1293,48 +1243,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: @@ -1351,7 +1259,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(), @@ -1383,7 +1291,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(), @@ -1824,90 +1732,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::*; @@ -1998,6 +1822,41 @@ 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" + )); + } +} + #[cfg(test)] mod quorum_lookup_context_error_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 96fe2dba014..f97699db05c 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 @@ -37,12 +38,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; use crate::wallet::asset_lock::AssetLockOperationContext; @@ -117,23 +116,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 // --------------------------------------------------------------------------- @@ -288,139 +270,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 // --------------------------------------------------------------------------- @@ -924,130 +773,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-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-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..59ed74ad29d --- /dev/null +++ b/packages/rs-sdk-cxx/include/dash/sdk/callbacks.h @@ -0,0 +1,182 @@ +// 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). 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 +//! 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..38a2a931ce7 --- /dev/null +++ b/packages/rs-sdk-cxx/src/client.rs @@ -0,0 +1,940 @@ +// 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::hashes::Hash; +use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, SecretKey}; +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::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::{ + 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::{ + 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, FetchMany}; +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, Invitation}; + +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) + } +} + +/// ... 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 { + 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() }); +result_from!(NameVotesResult { votes: Vec = Vec::new() }); +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 { + 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 = marshal::network(config.network)?; + 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 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], + 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 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], + proof: &ffi::AssetLockProof, + asset_lock_key_ref: u32, + signer: cxx::UniquePtr, + ) -> ffi::IdentityResult { + self.call("register_identity", |sdk| { + 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. + 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() + } + + 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], + 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], + 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 { + 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 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> { + 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::dpp::dashcore::Network; + 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}"); + } + 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. + #[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..562ac3c6b57 --- /dev/null +++ b/packages/rs-sdk-cxx/src/lib.rs @@ -0,0 +1,776 @@ +// 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; +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 { + #[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. + /// `state` is left empty by `current_contests`. + #[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 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 { + 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, + } + + /// 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, + } + + /// 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)] + 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 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, + 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; + /// 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; + /// 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). + 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; + /// 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. + 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 + /// `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; + 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], + 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) +} + +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], +) -> 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..18ab42a22a0 --- /dev/null +++ b/packages/rs-sdk-cxx/src/marshal.rs @@ -0,0 +1,501 @@ +// 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::hashes::Hash; +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; +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, 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::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::dashpay::{InviterInfo, ParsedInvitation, ProfileFields}; +use dash_sdk::platform::dpns_usernames::{convert_to_homograph_safe_chars, DpnsUsername}; +use dash_sdk::query_types::Contenders; + +use crate::ffi; + +/// 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 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(), + 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()) +} + +/// The keys of a new identity, each enabled and with a unique id. +pub fn new_identity_keys( + keys: &[ffi::IdentityKey], +) -> 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 => { + 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], + } +} + +/// `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 { + 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, + }) +} + +/// 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| { + 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| optional_property(document, "contactRequest", name, Value::to_binary_bytes); + 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()); + } + + #[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] + 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/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..3b0d3ad20fd --- /dev/null +++ b/packages/rs-sdk-cxx/tests/cxx_smoke.cc @@ -0,0 +1,375 @@ +// 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 +#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; +} + +//! 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 { + 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); + static_assert(sizeof(VoteChoice) == 1 && static_cast(VoteChoice::Lock) == 2); + + // 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(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"); + + // 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"); + 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"); + check(client->cast_masternode_vote(id, voting_key, "alice", static_cast(9), id, signer(false)).kind == + 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"), + "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; +} diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 64f0486d29e..8ed5f0992dd 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/dashpay/invitation.rs b/packages/rs-sdk/src/platform/dashpay/invitation.rs new file mode 100644 index 00000000000..d15884a8e9c --- /dev/null +++ b/packages/rs-sdk/src/platform/dashpay/invitation.rs @@ -0,0 +1,2506 @@ +//! 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, 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; +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, + }, + + /// 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. + #[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. + // `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| { + 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" => { + 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 [`InvitationError::NotChainLocked`], a retry-later signal. +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 +} + +/// 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 +/// `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 +/// 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). +/// +/// 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 +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(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(tx); + } + if attempt + 1 < CLAIM_FETCH_MAX_ATTEMPTS { + delay().await; + } + } + 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 +/// 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 — 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. +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| creation_fields(found) == creation_fields(key)) + }) +} + +/// 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::NotChainLocked( + "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 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"); + 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 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)) + .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 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"); + 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 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"); + 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 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" + ); + 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"); + } + + /// The link carries the plaintext voucher WIF, so a bearer link must not be + /// 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(); + 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). + #[test] + 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!( + 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 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"); + assert!(parsed.inviter.is_none()); + } + + #[test] + 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()); + // 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 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 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(); + assert!(err.to_string().contains("compressed")); + } + + #[test] + 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 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")); + } + + /// A hand-crafted Android-style link (params in Android's emit order, with + /// display-name + avatar-url) parses to the right fields. + #[test] + 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!( + "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 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); + 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 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); + let err = voucher_output_index(&tx, &key).unwrap_err(); + assert!(err.to_string().contains("does not control")); + } + + #[test] + fn should_reject_a_funding_tx_that_is_not_an_asset_lock() { + 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 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" + ); + 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 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(), + 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 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}"); + 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 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. + 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 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); + 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 + /// not claimable yet — the proof must prove finality — and that is the + /// retry-later signal, not an invalid link. + #[test] + 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!( + 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 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(); + 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 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(); + 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 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(); + 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 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 { + 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 should_derive_the_prospective_id_from_the_outpoint_alone() { + 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)); + } + + /// 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); + 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)); + } + + /// 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::{Error, 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(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(Error::Protocol(_)))) + } + + /// Run the seam with a scripted lookup, counting how often it is called. + async fn adopt( + submitted: Result, + lookup: Result, 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(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(Error::Generic( + "unrelated".to_string(), + ))), + Ok(Some(identity_with_keys(our_keys()))), + ) + .await; + assert!(matches!( + result, + Err(InvitationError::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 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(); + // 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, 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 should_return_a_canonical_hit_immediately() { + let (result, calls, delays) = run(vec![Ok(Some(7))]).await; + assert_eq!(result.expect("no error"), 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 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"), 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 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"), 3); + assert_eq!(calls.len(), 5); + assert_eq!(delays, 2); + } + + /// 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 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!( + 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); + } + + /// A transport error is NOT masked as a miss: it propagates + /// immediately, with no further lookups and no extra delay. + #[tokio::test] + 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![ + 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::{Error, 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() -> 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() -> Error { + 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 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"); + 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 should_resubmit_the_chain_fallback_after_a_stale_islock() { + 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 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(_))), + "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 should_propagate_an_unrelated_error_without_a_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 should_not_recover_a_rejected_fallback_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 881f1b2b88a..3789065cd31 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -1,10 +1,12 @@ -//! DashPay contact request helpers +//! DashPay contact request and invitation helpers //! //! This module provides helper functions for creating and sending DashPay contact requests -//! and for creating, updating and fetching DashPay profiles according to DIP-15 specification. +//! according to DIP-15 specification, for creating, updating and fetching DashPay profiles, +//! and the DashPay invitation link codec and claim. mod contact_request; mod contact_request_queries; +mod invitation; mod profile; pub use contact_request::{ @@ -13,6 +15,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, +}; pub use profile::ProfileFields; use crate::{Error, Sdk}; 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..75336ed9443 --- /dev/null +++ b/packages/rs-sdk/src/platform/transition/asset_lock_submission.rs @@ -0,0 +1,503 @@ +//! 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 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:** 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> { + match error.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::*; + use dpp::ProtocolError; + + /// 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 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; + + // 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:?}" + ); + } + } + + /// 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)] +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; + use dpp::ProtocolError; + + 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 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 should_recognize_a_broadcast_consensus_error_for_the_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 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); + + 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.