diff --git a/Cargo.lock b/Cargo.lock index 9f9dcea1a80..01c134c08b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4943,7 +4943,6 @@ dependencies = [ "grovedb-commitment-tree", "hex", "image", - "indexmap 2.13.1", "key-wallet", "key-wallet-manager", "platform-encryption", @@ -4968,12 +4967,12 @@ dependencies = [ "dashcore", "dpp", "hex", - "indexmap 2.13.1", "key-wallet", "lazy_static", "once_cell", "parking_lot", "platform-wallet", + "rs-sdk-ffi", "tempfile", "tokio", "zeroize", @@ -5981,6 +5980,7 @@ dependencies = [ "envy", "getrandom 0.2.17", "hex", + "key-wallet", "libc", "log", "once_cell", diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index 11101c8117a..e6d28509067 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -13,6 +13,10 @@ crate-type = ["staticlib", "cdylib", "rlib"] platform-wallet = { path = "../rs-platform-wallet" } dpp = { path = "../rs-dpp" } dash-sdk = { path = "../rs-sdk" } +# Needed for `SignerHandle` + `VTableSigner` so the `*_with_signer` +# entry points can accept iOS-side keychain-backed signers without +# duplicating the vtable plumbing. See `signer.rs` in rs-sdk-ffi. +rs-sdk-ffi = { path = "../rs-sdk-ffi" } # FFI utilities once_cell = "1.19" @@ -31,10 +35,6 @@ bincode = { version = "=2.0.1" } # Hex used for error diagnostics that include a wallet_id. hex = "0.4" -# Used for `IdentityManagerStartState.{identities, watched_identities}` -# IndexMaps when reconstructing the identity manager during load. -indexmap = "2.0" - # Zeroize intermediate key material crossing the FFI boundary. zeroize = { version = "1", features = ["derive"] } diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 05ca87637c9..8faf1ff208c 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -34,6 +34,7 @@ use std::ffi::CStr; use std::os::raw::c_char; use platform_wallet::ContactRequest; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::contact_request::CONTACT_REQUEST_STORAGE; use crate::error::*; @@ -505,6 +506,217 @@ pub unsafe extern "C" fn platform_wallet_accept_contact_request( }) } +// --------------------------------------------------------------------------- +// Send / accept contact request — external-signer variants +// --------------------------------------------------------------------------- + +/// Send a contact request to `recipient_id` using an +/// externally-supplied signer for the document state-transition. +/// +/// Mirrors [`platform_wallet_send_contact_request`] but signing is +/// routed through `signer_handle` instead of an internal +/// `IdentitySigner`. +/// +/// CAVEAT — ECDH derivation: the Rust side still derives the +/// sender's ECDH private key from the wallet seed for the contact +/// request encryption step. Watch-only wallets (no seed Rust-side) +/// will fail at that step. See the docstring on +/// [`IdentityWallet::send_contact_request_with_external_signer`](platform_wallet::IdentityWallet::send_contact_request_with_external_signer) +/// for the planned follow-up to push ECDH across the FFI as well. +/// +/// # Safety +/// Same null/lifetime rules as [`platform_wallet_send_contact_request`]. +/// Additionally `signer_handle` must be a valid, non-destroyed handle +/// produced by `dash_sdk_signer_create_with_ctx`. Caller retains +/// ownership. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( + wallet_handle: Handle, + sender_identity_id: *const u8, + recipient_identity_id: *const u8, + account_label: *const c_char, + auto_accept_proof: *const u8, + auto_accept_proof_len: usize, + signer_handle: *mut SignerHandle, + out_request_handle: *mut Handle, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + if out_request_handle.is_null() || signer_handle.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "out_request_handle or signer_handle is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + + let sender = match read_identifier(sender_identity_id) { + Ok(i) => i, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidIdentifier, + format!("Invalid sender identifier: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorInvalidIdentifier; + } + }; + let recipient = match read_identifier(recipient_identity_id) { + Ok(i) => i, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidIdentifier, + format!("Invalid recipient identifier: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorInvalidIdentifier; + } + }; + let label = if account_label.is_null() { + None + } else { + match CStr::from_ptr(account_label).to_str() { + Ok(s) => Some(s.to_string()), + Err(_) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorUtf8Conversion, + "account_label is not valid UTF-8", + ); + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + } + }; + let proof: Option> = if auto_accept_proof.is_null() || auto_accept_proof_len == 0 { + None + } else { + Some(std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec()) + }; + + let signer_addr = signer_handle as usize; + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + let result = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity + .send_contact_request_with_external_signer( + &sender, &recipient, label, proof, signer, + ) + .await + }); + match result { + Ok(request) => { + let handle = CONTACT_REQUEST_STORAGE.insert(request); + *out_request_handle = handle; + PlatformWalletFFIResult::Success + } + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!("send_contact_request_with_signer failed: {e}"), + ); + } + PlatformWalletFFIResult::ErrorWalletOperation + } + } + }) + .unwrap_or_else(|| { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid platform-wallet handle", + ); + } + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} + +/// Accept an incoming contact request using an externally-supplied +/// signer for the reciprocal request's document state-transition. +/// +/// Mirrors [`platform_wallet_accept_contact_request`] but the +/// reciprocal `send_contact_request` path uses the supplied +/// `signer_handle` instead of an internal `IdentitySigner`. Same +/// ECDH caveat applies — see +/// [`platform_wallet_send_contact_request_with_signer`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_accept_contact_request_with_signer( + wallet_handle: Handle, + request_handle: Handle, + signer_handle: *mut SignerHandle, + out_established_handle: *mut Handle, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + if out_established_handle.is_null() || signer_handle.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "out_established_handle or signer_handle is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + + let request = match CONTACT_REQUEST_STORAGE.with_item(request_handle, |req| req.clone()) { + Some(r) => r, + None => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid contact request handle", + ); + } + return PlatformWalletFFIResult::ErrorInvalidHandle; + } + }; + + let signer_addr = signer_handle as usize; + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + let result = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity + .accept_contact_request_with_external_signer(&request, signer) + .await + }); + match result { + Ok(contact) => { + let handle = ESTABLISHED_CONTACT_STORAGE.insert(contact); + *out_established_handle = handle; + PlatformWalletFFIResult::Success + } + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!("accept_contact_request_with_signer failed: {e}"), + ); + } + PlatformWalletFFIResult::ErrorWalletOperation + } + } + }) + .unwrap_or_else(|| { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid platform-wallet handle", + ); + } + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} + // --------------------------------------------------------------------------- // Reject contact request // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs b/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs index 9d31426e522..fd712189966 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs @@ -30,6 +30,7 @@ use std::os::raw::c_char; use std::ptr; use platform_wallet::{DashPayProfile, ProfileUpdate}; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::error::*; use crate::handle::*; @@ -367,8 +368,9 @@ pub unsafe extern "C" fn platform_wallet_sync_dashpay_profiles( ) -> PlatformWalletFFIResult { PLATFORM_WALLET_STORAGE .with_item(wallet_handle, |wallet| { - // Cheap Arc clone — same generic specialization as - // `platform_wallet_register_identity_from_addresses`. + // Cheap Arc clone — same generic specialization other + // identity-side FFI entry points use to hand the work to a + // tokio worker without dragging the wallet handle along. let identity = wallet.identity().clone(); let result = block_on_worker(async move { identity.sync_profiles().await }); match result { @@ -589,6 +591,164 @@ pub unsafe extern "C" fn platform_wallet_create_dashpay_profile( } } +/// Create or update a DashPay profile using an externally-supplied +/// signer. +/// +/// Mirrors [`platform_wallet_create_dashpay_profile`] / +/// [`platform_wallet_update_dashpay_profile`] but the document +/// state-transition signature crosses the FFI through the supplied +/// `signer_handle` (typically `KeychainSigner.handle`) instead of +/// through a wallet-derived `IdentitySigner`. Required for +/// external-signable wallets and the architecturally correct path +/// per `swift-sdk/CLAUDE.md`. +/// +/// `do_create` picks between the two operation paths on the Rust +/// side: `true` calls `create_profile_with_external_signer` (errors +/// when a profile already exists for the identity); `false` calls +/// `update_profile_with_external_signer` (errors when no profile +/// is on Platform yet). +/// +/// # Safety +/// Same null/lifetime rules as the legacy variants. Additionally +/// `signer_handle` must be a valid, non-destroyed handle produced by +/// `dash_sdk_signer_create_with_ctx`. Caller retains ownership. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_create_or_update_dashpay_profile_with_signer( + wallet_handle: Handle, + identity_id: *const u8, + display_name: *const c_char, + public_message: *const c_char, + avatar_url: *const c_char, + avatar_bytes: *const u8, + avatar_bytes_len: usize, + do_create: bool, + signer_handle: *mut SignerHandle, + out_profile: *mut DashPayProfileFFI, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + if out_profile.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "out_profile is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + if signer_handle.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "signer_handle is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + + let id = match read_identifier(identity_id) { + Ok(i) => i, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidIdentifier, + format!("Invalid identity identifier: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorInvalidIdentifier; + } + }; + + let display_name = match decode_opt_c_str(display_name, "display_name") { + Ok(v) => v, + Err(msg) => { + if !out_error.is_null() { + *out_error = + PlatformWalletFFIError::new(PlatformWalletFFIResult::ErrorUtf8Conversion, msg); + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + }; + let public_message = match decode_opt_c_str(public_message, "public_message") { + Ok(v) => v, + Err(msg) => { + if !out_error.is_null() { + *out_error = + PlatformWalletFFIError::new(PlatformWalletFFIResult::ErrorUtf8Conversion, msg); + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + }; + let avatar_url = match decode_opt_c_str(avatar_url, "avatar_url") { + Ok(v) => v, + Err(msg) => { + if !out_error.is_null() { + *out_error = + PlatformWalletFFIError::new(PlatformWalletFFIResult::ErrorUtf8Conversion, msg); + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + }; + + let avatar_bytes_vec: Option> = if avatar_bytes.is_null() || avatar_bytes_len == 0 { + None + } else { + Some(std::slice::from_raw_parts(avatar_bytes, avatar_bytes_len).to_vec()) + }; + + let signer_addr = signer_handle as usize; + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, move |wallet| { + let identity = wallet.identity().clone(); + let input = ProfileUpdate { + display_name, + public_message, + avatar_url, + avatar_bytes: avatar_bytes_vec, + }; + + let result = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + if do_create { + identity + .create_profile_with_external_signer(&id, input, signer) + .await + } else { + identity + .update_profile_with_external_signer(&id, input, signer) + .await + } + }); + + match result { + Ok(profile) => { + *out_profile = DashPayProfileFFI::from_profile(&profile); + PlatformWalletFFIResult::Success + } + Err(e) => { + if !out_error.is_null() { + let tag = if do_create { "create" } else { "update" }; + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!("{tag}_dashpay_profile_with_signer failed: {e}"), + ); + } + PlatformWalletFFIResult::ErrorWalletOperation + } + } + }) + .unwrap_or_else(|| { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid platform-wallet handle", + ); + } + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} + /// Update an existing DashPay profile document. Returns /// `ErrorWalletOperation` when no profile is on Platform yet — the /// caller should use [`platform_wallet_create_dashpay_profile`] in diff --git a/packages/rs-platform-wallet-ffi/src/derivation.rs b/packages/rs-platform-wallet-ffi/src/derivation.rs index cee10bf065b..e1548df16a4 100644 --- a/packages/rs-platform-wallet-ffi/src/derivation.rs +++ b/packages/rs-platform-wallet-ffi/src/derivation.rs @@ -24,6 +24,35 @@ use zeroize::Zeroizing; use crate::error::*; +/// Parse a BIP-39 mnemonic against every supported wordlist in turn, +/// returning the first language that yields a valid mnemonic. +/// +/// `key_wallet::Mnemonic` only exposes language-tagged constructors, +/// so user-mnemonic FFI entry points must walk the language list +/// themselves to avoid rejecting a French / Japanese / etc. phrase as +/// "invalid English". BIP-39 wordlists are mutually exclusive per +/// phrase (per the spec), so the first match is unambiguous. +fn parse_mnemonic_any_language(phrase: &str) -> Result { + const LANGUAGES: [Language; 10] = [ + Language::English, + Language::Spanish, + Language::French, + Language::Italian, + Language::Japanese, + Language::Korean, + Language::ChineseSimplified, + Language::ChineseTraditional, + Language::Czech, + Language::Portuguese, + ]; + for lang in LANGUAGES { + if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { + return Ok(m); + } + } + Err("phrase does not match any supported BIP-39 wordlist") +} + /// Derive a 32-byte ECDSA private key at a BIP-32 derivation path from /// a mnemonic phrase. /// @@ -160,7 +189,11 @@ pub unsafe extern "C" fn platform_wallet_derive_ext_priv_key_from_mnemonic( }; // ---- Derive ------------------------------------------------------------- - let mnemonic_obj = match Mnemonic::from_phrase(mnemonic_str, Language::English) { + // Auto-detect the BIP-39 language so non-English phrases (Spanish, + // French, Italian, Japanese, Korean, Chinese {Simplified, + // Traditional}, Czech, Portuguese) are accepted instead of being + // rejected as "invalid English". + let mnemonic_obj = match parse_mnemonic_any_language(mnemonic_str) { Ok(m) => m, Err(e) => { if !out_error.is_null() { diff --git a/packages/rs-platform-wallet-ffi/src/dpns.rs b/packages/rs-platform-wallet-ffi/src/dpns.rs index 3ffa23245a9..b26c610250a 100644 --- a/packages/rs-platform-wallet-ffi/src/dpns.rs +++ b/packages/rs-platform-wallet-ffi/src/dpns.rs @@ -1,19 +1,30 @@ //! FFI bindings for DPNS name operations on the platform-wallet //! [`IdentityWallet`](platform_wallet::IdentityWallet). //! -//! Three entry points: +//! Entry points (registration is split across two variants — see +//! the deprecation note on [`platform_wallet_register_dpns_name`]): //! -//! 1. [`platform_wallet_register_dpns_name`] — register a DPNS name -//! for an identity. Runs on the 8 MB tokio worker (proof -//! verification recurses), updates `ManagedIdentity.dpns_names` -//! on success, and persists via the identity changeset so the -//! Swift persister callback from `identity_persistence` will -//! refresh `PersistentIdentity.dpnsName` automatically. +//! 1. [`platform_wallet_register_dpns_name_with_signer`] — register a +//! DPNS name using an externally-supplied `SignerHandle` (the +//! iOS-side `KeychainSigner` in the SwiftExampleApp case). This +//! is the architecturally correct path per `swift-sdk/CLAUDE.md`: +//! the wallet's own seed never participates in signing, which +//! unblocks watch-only wallets where the seed lives in iOS +//! Keychain rather than the in-process `WalletManager`. //! -//! 2. [`platform_wallet_resolve_dpns_name`] — resolve a DPNS name +//! 2. [`platform_wallet_register_dpns_name`] (superseded) — older +//! seed-internal path that constructs an `IdentitySigner` from +//! the wallet manager. Kept around for the small set of callers +//! that haven't migrated yet; new code should call the +//! `_with_signer` variant. Both register a DPNS name, update +//! `ManagedIdentity.dpns_names` on success, and persist via the +//! identity changeset so `PersistentIdentity.dpnsName` refreshes +//! via `on_persist_identities_fn`. +//! +//! 3. [`platform_wallet_resolve_dpns_name`] — resolve a DPNS name //! to an identity id. Async; no persistence side-effects. //! -//! 3. [`platform_wallet_search_dpns_names`] — prefix search over +//! 4. [`platform_wallet_search_dpns_names`] — prefix search over //! Platform's DPNS documents. Async; returns a heap-allocated //! array of `DpnsSearchResultFFI` releasable via //! [`dpns_search_results_free`]. @@ -29,6 +40,8 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::ptr; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; + use crate::error::*; use crate::handle::*; use crate::runtime::block_on_worker; @@ -57,6 +70,17 @@ pub struct DpnsSearchResultFFI { /// `ManagedIdentity.dpns_names` on the Rust side and an identity /// changeset is queued so the Swift persister observes the update /// via `on_persist_identities_fn`. +/// +/// # Superseded — prefer [`platform_wallet_register_dpns_name_with_signer`] +/// +/// This entry point constructs an internal `IdentitySigner` from the +/// wallet manager and dies on watch-only wallets (no seed Rust-side). +/// It also re-acquires the wallet-manager lock from inside the +/// signing path, which can deadlock the Tokio worker if any callee +/// `blocking_read`s the same lock. New callers should use the +/// `_with_signer` variant and pass a `KeychainSigner.handle` from +/// Swift; this function stays in place for the small set of paths +/// that haven't migrated yet. #[no_mangle] pub unsafe extern "C" fn platform_wallet_register_dpns_name( wallet_handle: Handle, @@ -158,6 +182,160 @@ pub unsafe extern "C" fn platform_wallet_register_dpns_name( }) } +/// Register a DPNS name for an identity on Platform using an +/// externally-supplied signer. +/// +/// Replaces [`platform_wallet_register_dpns_name`] for any caller that +/// has a Swift-side `KeychainSigner` (every iOS path under the new +/// `_with_signer` regime). The wallet handle is still used to look up +/// the identity from the in-process `IdentityManager` so we can pick +/// the HIGH/CRITICAL authentication key the document state transition +/// requires — but every signature crosses the FFI through the +/// supplied `signer_handle` rather than via a wallet-derived +/// `IdentitySigner`. Works on watch-only wallets (no seed +/// Rust-side) and avoids the inner-lock-deadlock the legacy path +/// hit when the signer's private-key derivation tried to +/// `blocking_read` the wallet manager from inside the Tokio worker. +/// +/// Returns the full domain name (e.g. "alice.dash") via +/// `out_full_domain_name` — a heap-allocated C-string the caller must +/// release with [`crate::platform_wallet_string_free`]. +/// +/// On success the just-registered name is appended to +/// `ManagedIdentity.dpns_names` on the Rust side and an identity +/// changeset is queued so the Swift persister observes the update via +/// `on_persist_identities_fn` — identical book-keeping to the legacy +/// variant. +/// +/// # Safety +/// - `wallet_handle` must come from the platform-wallet handle +/// registry. +/// - `identity_id` must point at a valid 32-byte buffer for the +/// duration of the call. +/// - `name` must be a NUL-terminated UTF-8 C-string for the duration +/// of the call. +/// - `signer_handle` must be a valid, non-destroyed handle produced by +/// `dash_sdk_signer_create_with_ctx` (typically `KeychainSigner.handle`). +/// The caller retains ownership; this function does NOT destroy it. +/// - `out_full_domain_name` and `out_error` must be writable for the +/// duration of the call. `out_error` may be left null only when the +/// caller is willing to lose the diagnostic message. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_register_dpns_name_with_signer( + wallet_handle: Handle, + identity_id: *const u8, + name: *const c_char, + signer_handle: *mut SignerHandle, + out_full_domain_name: *mut *mut c_char, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + if name.is_null() || out_full_domain_name.is_null() || signer_handle.is_null() { + if !out_error.is_null() { + unsafe { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "name, out_full_domain_name, or signer_handle is null", + ); + } + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + + let id = match unsafe { read_identifier(identity_id) } { + Ok(i) => i, + Err(e) => { + if !out_error.is_null() { + unsafe { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidIdentifier, + format!("Invalid identity identifier: {e}"), + ); + } + } + return PlatformWalletFFIResult::ErrorInvalidIdentifier; + } + }; + let name_str = match unsafe { CStr::from_ptr(name) }.to_str() { + Ok(s) => s.to_string(), + Err(_) => { + if !out_error.is_null() { + unsafe { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorUtf8Conversion, + "name is not valid UTF-8", + ); + } + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + }; + + // Round-trip the signer pointer through `usize` so the spawned + // future has a `Send + 'static` capture (raw pointers are `!Send`, + // but `usize` is). The underlying `VTableSigner`'s `Inner::Callback + // { ctx, vtable }` is `Send + Sync` (see the unsafe impls in + // `rs-sdk-ffi/src/signer.rs`). + let signer_addr = signer_handle as usize; + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + // SAFETY: caller guarantees `signer_handle` is valid and + // outlives this call; `signer_addr` is the same pointer + // reinterpreted as `usize` for the `Send` capture below. + let result = block_on_worker(async move { + let signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + identity_wallet + .register_name_with_external_signer(&id, &name_str, signer) + .await + }); + match result { + Ok(full_name) => match CString::new(full_name) { + Ok(cstr) => { + unsafe { *out_full_domain_name = cstr.into_raw() }; + PlatformWalletFFIResult::Success + } + Err(_) => { + // Defensive — DPNS labels never carry an + // interior NUL today, but guard against a + // future encoding change. + if !out_error.is_null() { + unsafe { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorSerialization, + "full domain name contained NUL", + ); + } + } + PlatformWalletFFIResult::ErrorSerialization + } + }, + Err(e) => { + if !out_error.is_null() { + unsafe { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!("register_dpns_name_with_signer failed: {e}"), + ); + } + } + PlatformWalletFFIResult::ErrorWalletOperation + } + } + }) + .unwrap_or_else(|| { + if !out_error.is_null() { + unsafe { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid platform-wallet handle", + ); + } + } + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} + /// Resolve a DPNS name (`"alice"` or `"alice.dash"`) to an identity id. /// /// `out_found` reports whether the lookup returned a hit. When `true`, diff --git a/packages/rs-platform-wallet-ffi/src/identity_key_preview.rs b/packages/rs-platform-wallet-ffi/src/identity_key_preview.rs index 7b76f0ab2ff..9ef23d78532 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_key_preview.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_key_preview.rs @@ -44,6 +44,14 @@ use crate::handle::*; /// `public_key` buffer is exactly 33 bytes — a compressed /// secp256k1 public key — so callers can read `public_key_len` /// defensively without assuming the constant. +/// +/// `private_key_bytes` is the raw 32-byte ECDSA scalar — needed by +/// the Swift side so it can persist the key into the iOS Keychain +/// before calling [`platform_wallet_register_identity_with_signer`] +/// (the Swift `KeychainSigner` then re-reads it during +/// state-transition signing). `private_key_wif` carries the same +/// material in the human-readable WIF form for the keychain +/// explorer / debugging UI. #[repr(C)] pub struct IdentityKeyPreviewFFI { /// Identity index (BIP-9 position under the identity branch). @@ -60,6 +68,12 @@ pub struct IdentityKeyPreviewFFI { /// the private key. Network-aware (mainnet vs testnet/devnet/ /// regtest version byte) and compressed. pub private_key_wif: *mut c_char, + /// Raw 32-byte ECDSA private-key scalar. Inline — no heap + /// allocation — so the freed-rows path doesn't need to chase a + /// pointer for it. Treat as sensitive material: the Swift side + /// is expected to copy it straight into the iOS Keychain and + /// drop the local reference. + pub private_key_bytes: [u8; 32], } /// Heap-allocated array of [`IdentityKeyPreviewFFI`] rows. Release @@ -253,6 +267,7 @@ pub unsafe extern "C" fn platform_wallet_preview_identity_registration_keys( public_key: pub_ptr, public_key_len: pub_len, private_key_wif: wif_cstring.into_raw(), + private_key_bytes: ext_priv.private_key.secret_bytes(), }); } diff --git a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs new file mode 100644 index 00000000000..f3b962468cb --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs @@ -0,0 +1,648 @@ +//! Mnemonic-driven identity-registration key derivation. +//! +//! Surgical companion to +//! [`crate::identity_registration_with_signer::platform_wallet_derive_identity_keys_for_index`] +//! — same output shape, different input. Where the wallet-handle +//! variant requires a full `key_wallet::Wallet` with an in-process +//! xpriv loaded, this entry point takes the BIP-39 mnemonic directly +//! and never touches the wallet manager. +//! +//! # Why this exists +//! +//! Wallets restored from a Swift-side persisted state (SwiftData +//! row + Keychain mnemonic) load into the Rust process as +//! **watch-only** — the seed is in iOS Keychain, not in the +//! `WalletManager`. Calling +//! [`platform_wallet_derive_identity_keys_for_index`](crate::platform_wallet_derive_identity_keys_for_index) +//! on those wallets fails with `"Cannot derive private keys from +//! watch-only wallet"` because `Wallet::derive_extended_private_key` +//! refuses to operate without the seed. +//! +//! Rather than push the seed across the FFI just to load it into the +//! `WalletManager` (which would defeat the watch-only model that the +//! rest of this crate carefully preserves), the Swift caller hands +//! us the mnemonic for the duration of one call. The mnemonic is +//! parsed, converted to a 64-byte seed inside a [`Zeroizing`] buffer, +//! used to build the master xpriv, and walked through `key_count` +//! derivation paths — all within this function's lifetime. The seed +//! is wrapped; the intermediate `ExtendedPrivKey`s aren't and rely on +//! the underlying `secp256k1::SecretKey` drop path to clear the +//! secret. Only the final 32-byte secret scalars cross the FFI; the +//! `_free` path additionally zeroizes the inline `private_key_bytes` +//! and the WIF buffer in place before the row slab is released. +//! +//! The output rows reuse [`IdentityKeyPreviewFFI`] and the wrapper +//! reuses [`crate::identity_registration_with_signer::IdentityRegistrationKeyDerivationsFFI`] +//! so the Swift marshalling code already written for the wallet-handle +//! variant works unchanged. + +use std::ffi::CString; + +use dashcore::secp256k1::Secp256k1; +use dashcore::PrivateKey as DashPrivateKey; +use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey}; +use key_wallet::dip9::{ + IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, +}; +use key_wallet::mnemonic::{Language, Mnemonic}; +use rs_sdk_ffi::DashSDKNetwork; +use zeroize::Zeroizing; + +use crate::error::*; +use crate::identity_key_preview::IdentityKeyPreviewFFI; +use crate::identity_registration_with_signer::IdentityRegistrationKeyDerivationsFFI; + +/// Parse a BIP-39 mnemonic against every supported wordlist. +/// +/// Mirrors the auto-detect helper in `rs-sdk-ffi::signer_simple` +/// (`parse_mnemonic_any_language`); kept inline here so this crate +/// doesn't have to take a public dependency on that crate-internal +/// helper. BIP-39 wordlists are mutually exclusive within a single +/// phrase, so the first language that yields a valid mnemonic is the +/// right one. +fn parse_mnemonic_any_language(phrase: &str) -> Result { + const LANGUAGES: [Language; 10] = [ + Language::English, + Language::Spanish, + Language::French, + Language::Italian, + Language::Japanese, + Language::Korean, + Language::ChineseSimplified, + Language::ChineseTraditional, + Language::Czech, + Language::Portuguese, + ]; + for lang in LANGUAGES { + if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { + return Ok(m); + } + } + Err("phrase does not match any supported BIP-39 wordlist") +} + +/// Map the C-ABI `DashSDKNetwork` enum to `key_wallet::Network`. +/// +/// `Local` collapses to `Regtest` to match the rest of the FFI surface +/// (`dash_sdk_signer_create_from_private_key`, `dash_sdk_sign_with_mnemonic_and_path`). +fn map_network(network: DashSDKNetwork) -> key_wallet::Network { + match network { + DashSDKNetwork::SDKMainnet => key_wallet::Network::Mainnet, + DashSDKNetwork::SDKTestnet => key_wallet::Network::Testnet, + DashSDKNetwork::SDKRegtest => key_wallet::Network::Regtest, + DashSDKNetwork::SDKDevnet => key_wallet::Network::Devnet, + DashSDKNetwork::SDKLocal => key_wallet::Network::Regtest, + } +} + +/// Build the DIP-9 identity-authentication derivation path +/// `m/9'/coin'/5'/0'/0'/identity_index'/key_index'`. +/// +/// Coin type is selected by `network` (mainnet vs testnet). The hardened +/// `0'` slot in position 4 is `KeyDerivationType::ECDSA`; this helper +/// hardcodes ECDSA because that's the only key type the identity- +/// registration path supports today (matches `derive_identity_auth_keypair` +/// in `platform-wallet`, which is the source of truth for live +/// registrations / discovery / preview). +/// +/// Re-derived locally instead of borrowed from `platform-wallet` because +/// `platform_wallet::identity_auth_derivation_path` is `pub(crate)` and +/// this entry point sits outside that crate. +fn identity_auth_derivation_path( + network: key_wallet::Network, + identity_index: u32, + key_index: u32, +) -> Result { + let base_path: DerivationPath = match network { + key_wallet::Network::Mainnet => IDENTITY_AUTHENTICATION_PATH_MAINNET, + _ => IDENTITY_AUTHENTICATION_PATH_TESTNET, + } + .into(); + // KeyDerivationType::ECDSA = 0 — hardcoded to match the only path + // shape `derive_identity_auth_keypair` ever produces. + let key_type_index: u32 = 0; + Ok(base_path.extend([ + ChildNumber::from_hardened_idx(key_type_index) + .map_err(|e| format!("invalid key_type_index: {e}"))?, + ChildNumber::from_hardened_idx(identity_index) + .map_err(|e| format!("invalid identity_index {identity_index}: {e}"))?, + ChildNumber::from_hardened_idx(key_index) + .map_err(|e| format!("invalid key_index {key_index}: {e}"))?, + ])) +} + +/// Derive `key_count` identity-registration keys from +/// `(mnemonic, passphrase, network)` at DIP-9 paths +/// `m/9'/coin'/5'/0'/0'/identity_index'/key_index'` for +/// `key_index` in `0..key_count`. +/// +/// Returns rows of `(pubkey_bytes, derivation_path_cstr, +/// private_key_bytes, private_key_wif)` via the shared +/// [`IdentityRegistrationKeyDerivationsFFI`] / [`IdentityKeyPreviewFFI`] +/// shape — same memory layout as the wallet-handle variant +/// [`platform_wallet_derive_identity_keys_for_index`](crate::platform_wallet_derive_identity_keys_for_index) +/// so the Swift marshalling and free path are identical. +/// +/// The 64-byte mnemonic seed is wrapped in [`Zeroizing`] for the +/// duration of the call. Intermediate `ExtendedPrivKey` values +/// (`master`, `derived`) are *not* wrapped — they currently rely on +/// the underlying `secp256k1::SecretKey`'s drop path to scrub itself +/// rather than an explicit `Zeroizing` wrapper. Only the final +/// 32-byte secret scalars cross the FFI boundary for the caller to +/// persist into Keychain; on the `_free` path both the inline +/// `private_key_bytes` and the WIF buffer are zeroized in place +/// before the row slab is released. +/// +/// # Why this exists +/// Identity-key derivation that previously routed through the wallet +/// handle ([`platform_wallet_derive_identity_keys_for_index`](crate::platform_wallet_derive_identity_keys_for_index)) +/// fails for restored watch-only wallets because Rust has no xpriv +/// loaded for them. This entry point bypasses the wallet entirely — +/// the caller supplies the mnemonic. +/// +/// # Parameters +/// - `mnemonic_cstr`: null-terminated UTF-8 BIP-39 phrase. Auto- +/// detects language from the supported wordlists. +/// - `passphrase_cstr`: null-terminated UTF-8 BIP-39 passphrase. May +/// be null, in which case the empty passphrase is used. +/// - `network`: selects the coin-type slot in the derivation path +/// AND the WIF version byte. +/// - `identity_index`: hardened identity index slot. +/// - `key_count`: number of consecutive `key_index` slots to derive, +/// starting at 0. +/// - `out_rows`: populated on success with a heap-allocated array. +/// Release with [`dash_sdk_derive_identity_keys_from_mnemonic_free`]. +/// - `out_error`: populated on failure with the usual +/// [`PlatformWalletFFIError`] detail. +/// +/// On error `*out_rows` is left at its zero state. +/// +/// # Safety +/// - `mnemonic_cstr` must be a valid, null-terminated UTF-8 C string +/// for the duration of the call. +/// - `passphrase_cstr` may be null; otherwise must be a valid +/// null-terminated UTF-8 C string. +/// - `out_rows` must be a valid, writable pointer to a +/// `IdentityRegistrationKeyDerivationsFFI`. Caller retains +/// ownership of the outer struct; this function fills it in place. +/// - `out_error` may be null. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn dash_sdk_derive_identity_keys_from_mnemonic( + mnemonic_cstr: *const std::os::raw::c_char, + passphrase_cstr: *const std::os::raw::c_char, + network: DashSDKNetwork, + identity_index: u32, + key_count: u32, + out_rows: *mut IdentityRegistrationKeyDerivationsFFI, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + use std::ffi::CStr; + + if out_rows.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "out_rows is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + // Pre-zero so a failed call leaves the caller staring at a known + // empty struct, never uninitialized memory. + *out_rows = IdentityRegistrationKeyDerivationsFFI { + items: std::ptr::null_mut(), + count: 0, + }; + + if mnemonic_cstr.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "mnemonic_cstr is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + + if key_count == 0 { + return PlatformWalletFFIResult::Success; + } + + // ---- UTF-8 inputs -------------------------------------------------------- + let mnemonic_str = match CStr::from_ptr(mnemonic_cstr).to_str() { + Ok(s) => s, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorUtf8Conversion, + format!("mnemonic_cstr is not valid UTF-8: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + }; + let passphrase_str: &str = if passphrase_cstr.is_null() { + "" + } else { + match CStr::from_ptr(passphrase_cstr).to_str() { + Ok(s) => s, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorUtf8Conversion, + format!("passphrase_cstr is not valid UTF-8: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + } + }; + + // ---- Mnemonic + seed ----------------------------------------------------- + let mnemonic = match parse_mnemonic_any_language(mnemonic_str) { + Ok(m) => m, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!("invalid mnemonic: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + // 64-byte seed wrapped in `Zeroizing` so it gets scrubbed when this + // function returns (success or failure). `to_seed` returns by value; + // wrapping at the call site is the earliest we can intercept it. + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed(passphrase_str)); + + // ---- Master xpriv -------------------------------------------------------- + let kw_network = map_network(network); + let master = match ExtendedPrivKey::new_master(kw_network, seed.as_ref()) { + Ok(m) => m, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!("ExtendedPrivKey::new_master failed: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorWalletOperation; + } + }; + let secp = Secp256k1::new(); + + // ---- Walk key_count derivation paths ------------------------------------- + // + // Build the row vec up-front so we can unwind ownership of every + // CString / Vec / Box we've already detached if a later iteration + // fails. `Vec::drop` would NOT free those raw pointers — they are + // exposed via `into_raw` / `forget(Box::...)` and only the paired + // free function reclaims them. + let mut rows: Vec = Vec::with_capacity(key_count as usize); + + // Hand-roll cleanup matching the wallet-handle variant's pattern. + let cleanup = |rows: Vec| { + for row in rows { + if !row.derivation_path.is_null() { + let _ = CString::from_raw(row.derivation_path); + } + if !row.public_key.is_null() && row.public_key_len > 0 { + let _ = Vec::from_raw_parts(row.public_key, row.public_key_len, row.public_key_len); + } + if !row.private_key_wif.is_null() { + let _ = CString::from_raw(row.private_key_wif); + } + } + }; + + for key_index in 0..key_count { + let path = match identity_auth_derivation_path(kw_network, identity_index, key_index) { + Ok(p) => p, + Err(detail) => { + cleanup(rows); + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!( + "derive_identity_keys_from_mnemonic: path build failed at \ + (identity={identity_index}, key={key_index}): {detail}" + ), + ); + } + return PlatformWalletFFIResult::ErrorWalletOperation; + } + }; + + // The intermediate `derived` xpriv carries a 32-byte secret in + // the clear; we extract `.private_key.secret_bytes()` into the + // FFI row and let the xpriv fall out of scope at the end of + // the iteration. The seed remains zeroized regardless. + let derived = match master.derive_priv(&secp, &path) { + Ok(d) => d, + Err(e) => { + cleanup(rows); + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!( + "derive_identity_keys_from_mnemonic: derive_priv failed at \ + (identity={identity_index}, key={key_index}): {e}" + ), + ); + } + return PlatformWalletFFIResult::ErrorWalletOperation; + } + }; + let extended_pub = ExtendedPubKey::from_priv(&secp, &derived); + let public_key = extended_pub.public_key; + + let path_cstring = match CString::new(path.to_string()) { + Ok(s) => s, + Err(e) => { + cleanup(rows); + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorUtf8Conversion, + format!("derivation path contained NUL byte: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + }; + + // Compressed secp256k1 pubkey is exactly 33 bytes. + let pub_bytes: [u8; 33] = public_key.serialize(); + let mut pub_box: Box<[u8]> = pub_bytes.to_vec().into_boxed_slice(); + let pub_ptr = pub_box.as_mut_ptr(); + let pub_len = pub_box.len(); + std::mem::forget(pub_box); + + // WIF for the keychain-explorer / debugging UI. Same network- + // aware shape as the wallet-handle variant produces. + let dash_private = DashPrivateKey { + compressed: true, + network: kw_network, + inner: derived.private_key, + }; + let wif_cstring = match CString::new(dash_private.to_wif()) { + Ok(s) => s, + Err(e) => { + // Path cstring + pubkey buffer were already detached. + drop(Vec::from_raw_parts(pub_ptr, pub_len, pub_len)); + drop(path_cstring); + cleanup(rows); + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorUtf8Conversion, + format!("WIF string contained NUL byte: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + }; + + rows.push(IdentityKeyPreviewFFI { + identity_index, + derivation_path: path_cstring.into_raw(), + public_key: pub_ptr, + public_key_len: pub_len, + private_key_wif: wif_cstring.into_raw(), + // 32-byte secret scalar — copied by value into the FFI + // row. `derived` goes out of scope at the end of the + // loop body; the seed is still zeroized at function exit. + private_key_bytes: derived.private_key.secret_bytes(), + }); + } + + let mut boxed = rows.into_boxed_slice(); + let items_ptr = boxed.as_mut_ptr(); + let items_count = boxed.len(); + std::mem::forget(boxed); + + *out_rows = IdentityRegistrationKeyDerivationsFFI { + items: items_ptr, + count: items_count, + }; + PlatformWalletFFIResult::Success +} + +/// Release a [`IdentityRegistrationKeyDerivationsFFI`] previously +/// populated by [`dash_sdk_derive_identity_keys_from_mnemonic`]. +/// +/// Safe to call on a zero / null struct or null outer pointer (no-op). +/// Each row's owned strings (`derivation_path`, `private_key_wif`) +/// and pubkey buffer are reclaimed. +/// +/// Behaviorally identical to +/// [`platform_wallet_derive_identity_keys_for_index_free`](crate::platform_wallet_derive_identity_keys_for_index_free) +/// — they consume the same struct shape — but kept as a separate +/// symbol so the Swift call site can pair allocator with deallocator +/// 1:1 by name. +/// +/// # Safety +/// `rows.items` must have been handed out by +/// [`dash_sdk_derive_identity_keys_from_mnemonic`] (or the wallet-handle +/// variant — same layout) and must not be freed twice. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_derive_identity_keys_from_mnemonic_free( + rows: *mut IdentityRegistrationKeyDerivationsFFI, +) { + if rows.is_null() { + return; + } + let owned = std::mem::replace( + &mut *rows, + IdentityRegistrationKeyDerivationsFFI { + items: std::ptr::null_mut(), + count: 0, + }, + ); + if owned.items.is_null() || owned.count == 0 { + return; + } + let slice = std::slice::from_raw_parts_mut(owned.items, owned.count); + for row in slice.iter_mut() { + if !row.derivation_path.is_null() { + let _ = CString::from_raw(row.derivation_path); + } + if !row.public_key.is_null() && row.public_key_len > 0 { + let _ = Vec::from_raw_parts(row.public_key, row.public_key_len, row.public_key_len); + } + if !row.private_key_wif.is_null() { + // The WIF string encodes the same 32-byte secret as + // `private_key_bytes`; scrub the buffer in place before + // dropping so the heap allocation isn't released with + // recoverable key material. + let mut wif = CString::from_raw(row.private_key_wif).into_bytes_with_nul(); + zeroize::Zeroize::zeroize(&mut wif); + row.private_key_wif = std::ptr::null_mut(); + } + // Final inline secret scalar — wipe before the row slab is + // returned to the allocator. + zeroize::Zeroize::zeroize(&mut row.private_key_bytes); + } + let _ = Box::from_raw(slice as *mut [IdentityKeyPreviewFFI]); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// English BIP-39 test vector (all-zero entropy). Same fixture + /// the upstream `bip39` crate uses for round-trip tests; reused + /// here so the assertions match a well-known mnemonic. + const ENGLISH_PHRASE: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + /// Sanity-check the happy path on testnet with a small key count. + /// We don't assert specific hex bytes — derive determinism is the + /// platform-wallet / key-wallet test surface — but we DO assert + /// the shape contract: row count, pubkey length, path string + /// shape, and that each row carries non-zero secret bytes. + #[test] + fn derives_three_keys_with_correct_shape() { + let mnemonic = std::ffi::CString::new(ENGLISH_PHRASE).unwrap(); + let mut out = IdentityRegistrationKeyDerivationsFFI { + items: std::ptr::null_mut(), + count: 0, + }; + let mut err = PlatformWalletFFIError::success(); + let result = unsafe { + dash_sdk_derive_identity_keys_from_mnemonic( + mnemonic.as_ptr(), + std::ptr::null(), // empty passphrase + DashSDKNetwork::SDKTestnet, + 7, + 3, + &mut out, + &mut err, + ) + }; + assert_eq!(result, PlatformWalletFFIResult::Success); + assert_eq!(out.count, 3); + assert!(!out.items.is_null()); + + for i in 0..out.count { + let row = unsafe { &*out.items.add(i) }; + assert_eq!(row.identity_index, 7); + assert_eq!(row.public_key_len, 33); + assert!(!row.public_key.is_null()); + assert!(!row.derivation_path.is_null()); + + let path = unsafe { std::ffi::CStr::from_ptr(row.derivation_path) } + .to_str() + .unwrap(); + // m/9'/1'/5'/0'/0'/7'/' on testnet (coin_type = 1). + // We just sanity-check the structural prefix and the trailing + // key index — exact path strings are covered by platform-wallet. + assert!( + path.starts_with("m/9'/1'/5'/0'/0'/7'/"), + "unexpected path prefix: {path}" + ); + assert!( + path.ends_with(&format!("/{i}'")), + "unexpected path tail: {path}" + ); + + // Secret scalar must be non-zero; if it were, derivation + // silently no-op'd. + assert!( + row.private_key_bytes.iter().any(|b| *b != 0), + "secret bytes are all zero at row {i}" + ); + } + + unsafe { dash_sdk_derive_identity_keys_from_mnemonic_free(&mut out) }; + // `_free` resets the outer struct. + assert!(out.items.is_null()); + assert_eq!(out.count, 0); + } + + /// Mainnet path uses coin_type = 5 instead of 1. Lightweight + /// coverage that the network mapping flows into the path. + #[test] + fn derives_mainnet_path_uses_coin_type_5() { + let mnemonic = std::ffi::CString::new(ENGLISH_PHRASE).unwrap(); + let mut out = IdentityRegistrationKeyDerivationsFFI { + items: std::ptr::null_mut(), + count: 0, + }; + let mut err = PlatformWalletFFIError::success(); + let result = unsafe { + dash_sdk_derive_identity_keys_from_mnemonic( + mnemonic.as_ptr(), + std::ptr::null(), + DashSDKNetwork::SDKMainnet, + 0, + 1, + &mut out, + &mut err, + ) + }; + assert_eq!(result, PlatformWalletFFIResult::Success); + assert_eq!(out.count, 1); + + let row = unsafe { &*out.items }; + let path = unsafe { std::ffi::CStr::from_ptr(row.derivation_path) } + .to_str() + .unwrap(); + assert_eq!(path, "m/9'/5'/5'/0'/0'/0'/0'"); + + unsafe { dash_sdk_derive_identity_keys_from_mnemonic_free(&mut out) }; + } + + /// `key_count == 0` is a legal no-op and must not allocate. + #[test] + fn key_count_zero_is_noop_success() { + let mnemonic = std::ffi::CString::new(ENGLISH_PHRASE).unwrap(); + let mut out = IdentityRegistrationKeyDerivationsFFI { + items: std::ptr::null_mut(), + count: 0, + }; + let mut err = PlatformWalletFFIError::success(); + let result = unsafe { + dash_sdk_derive_identity_keys_from_mnemonic( + mnemonic.as_ptr(), + std::ptr::null(), + DashSDKNetwork::SDKTestnet, + 0, + 0, + &mut out, + &mut err, + ) + }; + assert_eq!(result, PlatformWalletFFIResult::Success); + assert_eq!(out.count, 0); + assert!(out.items.is_null()); + // `_free` is a no-op on the empty struct. + unsafe { dash_sdk_derive_identity_keys_from_mnemonic_free(&mut out) }; + } + + /// Garbage mnemonic must surface `ErrorInvalidParameter`, not a + /// crash, and must not leave a partial allocation behind. + #[test] + fn rejects_invalid_mnemonic() { + let mnemonic = std::ffi::CString::new("not a real bip39 phrase at all here").unwrap(); + let mut out = IdentityRegistrationKeyDerivationsFFI { + items: std::ptr::null_mut(), + count: 0, + }; + let mut err = PlatformWalletFFIError::success(); + let result = unsafe { + dash_sdk_derive_identity_keys_from_mnemonic( + mnemonic.as_ptr(), + std::ptr::null(), + DashSDKNetwork::SDKTestnet, + 0, + 3, + &mut out, + &mut err, + ) + }; + assert_eq!(result, PlatformWalletFFIResult::ErrorInvalidParameter); + assert!(out.items.is_null()); + assert_eq!(out.count, 0); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration.rs b/packages/rs-platform-wallet-ffi/src/identity_registration.rs index ab39b8b68c1..d33bcb4f6d0 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration.rs @@ -1,62 +1,19 @@ -//! FFI for address-funded identity registration. +//! FFI types shared by the identity-registration entry points. //! -//! Exposes `IdentityWallet::register_from_addresses` through a single C -//! entry point. Inputs arrive as a flat array of `(addressType, hash20, -//! nonce, credits)` tuples. The optional refund output is expressed as -//! a sibling triple plus an `has_output` flag. +//! Historically this module also exposed +//! `platform_wallet_register_identity_from_addresses`, a mnemonic-driven +//! identity-registration FFI. That entry point has been deleted: the +//! `_with_signer` variant in +//! [`crate::identity_registration_with_signer`] supersedes it (the +//! mnemonic no longer crosses the FFI for identity registration; signing +//! is routed through external `SignerHandle`s instead — see +//! `swift-sdk/CLAUDE.md` for the architectural reasoning). //! -//! The caller supplies the BIP-39 mnemonic (typically from iOS -//! Keychain) plus an optional passphrase. This layer: -//! -//! 1. Derives a seed from the mnemonic. -//! 2. Derives every DIP-9 identity-authentication pubkey at -//! `m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'` for -//! `key_index ∈ 0..key_count`. -//! 3. Builds a placeholder `Identity` carrying those pubkeys. -//! 4. Spins up `SeedBackedIdentitySigner` + `SeedBackedPlatformAddressSigner` -//! against the same seed. -//! 5. Hands everything to the thin-wrapper -//! `IdentityWallet::register_from_addresses`. -//! -//! The wallet struct stays key-material-free (WatchOnly / -//! ExternalSignable). The seed + intermediate xprivs live only for -//! the duration of this call — wrapped in `Zeroizing` where the -//! upstream types support it. -//! -//! Returns the newly-created identity through two out-params: -//! * `out_identity_id` — the 32-byte platform identifier. -//! * `out_identity_handle` — a handle into `MANAGED_IDENTITY_STORAGE` -//! pointing at a `platform_wallet::ManagedIdentity` wrapping the -//! Identity + its HD `identity_index`. The caller owns the handle -//! and must free it via `managed_identity_destroy` when done. - -use dashcore::secp256k1::Secp256k1; -use dpp::address_funds::PlatformAddress; -use dpp::fee::Credits; -use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; -use dpp::identity::v0::IdentityV0; -use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; -use dpp::platform_value::BinaryData; -use dpp::prelude::Identifier; -use key_wallet::bip32::{ - ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, KeyDerivationType, -}; -use key_wallet::dip9::{ - IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, -}; -use key_wallet::mnemonic::{Language, Mnemonic}; -use key_wallet::Network; -use platform_wallet::wallet::signer::{SeedBackedIdentitySigner, SeedBackedPlatformAddressSigner}; -use std::collections::BTreeMap; -use std::ffi::CStr; -use std::os::raw::c_char; -use std::slice; -use zeroize::Zeroizing; - -use crate::error::*; -use crate::handle::*; -use crate::runtime::block_on_worker; +//! The two flat input/output structs below survived the deletion +//! because the `_with_signer` FFI reuses them verbatim. Keeping them +//! here (rather than moving them next to the `_with_signer` fn) keeps +//! the public C ABI stable for any out-of-tree consumers that already +//! `#include` these struct shapes. /// Flat input entry matching the SDK `put_with_address_funding` /// shape. One row per contributing Platform Payment address. @@ -67,7 +24,7 @@ use crate::runtime::block_on_worker; /// the lower-level SDK API directly. #[repr(C)] #[derive(Debug, Clone, Copy)] -pub struct IdentityInputAddressFFI { +pub struct IdentityFundingInputFFI { /// Address type discriminant (0 = P2PKH, 1 = P2SH). Matches the /// encoding used by `PlatformAddressFFI`. pub address_type: u8, @@ -81,343 +38,9 @@ pub struct IdentityInputAddressFFI { /// `has_output` is false the remaining fields are ignored. #[repr(C)] #[derive(Debug, Clone, Copy)] -pub struct IdentityOutputAddressFFI { +pub struct IdentityFundingOutputFFI { pub has_output: bool, pub address_type: u8, pub hash: [u8; 20], pub credits: u64, } - -/// Register a new identity funded by Platform-address balances. -/// -/// On success both `out_identity_id` (32 bytes) and -/// `out_identity_handle` are populated. The returned handle points at -/// a freshly-inserted `ManagedIdentity` in `MANAGED_IDENTITY_STORAGE` -/// wrapping the new identity together with `identity_index`. The -/// caller owns the handle and must release it via -/// `managed_identity_destroy`. -/// -/// Note: the wallet's internal `IdentityManager` also receives a copy -/// of the same identity (via -/// `IdentityWallet::register_from_addresses`), so this handle is a -/// convenience for the caller to query right after creation — the -/// canonical source of truth remains the wallet. -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_register_identity_from_addresses( - wallet_handle: Handle, - identity_index: u32, - key_count: u32, - // BIP-39 mnemonic phrase (UTF-8, English). Used to derive - // the DIP-9 auth keys + sign both the new identity and each - // spent platform address. Not retained beyond this call. - mnemonic: *const c_char, - // Optional BIP-39 passphrase. Pass NULL for the empty - // passphrase. - passphrase: *const c_char, - inputs: *const IdentityInputAddressFFI, - inputs_count: usize, - // Pointer rather than by-value because a 32-byte C struct - // straddles the "passed by register vs. by stack" boundary - // differently across toolchains. Swift + Rust agree on - // pointer ABI, so we dodge that question entirely. - output: *const IdentityOutputAddressFFI, - out_identity_id: *mut [u8; 32], - out_identity_handle: *mut Handle, - out_error: *mut PlatformWalletFFIError, -) -> PlatformWalletFFIResult { - // Distinct messages per pointer so the caller can tell which - // invariant was violated. Swift currently surfaces `.nullPointer` - // generically; the detail here makes the alert actionable. - let invariant_violation: Option<&'static str> = if inputs.is_null() { - Some("`inputs` pointer is null") - } else if inputs_count == 0 { - Some("`inputs_count` is zero") - } else if mnemonic.is_null() { - Some("`mnemonic` pointer is null") - } else if out_identity_id.is_null() { - Some("`out_identity_id` pointer is null") - } else if out_identity_handle.is_null() { - Some("`out_identity_handle` pointer is null") - } else if key_count == 0 { - Some("`key_count` must be >= 1") - } else { - None - }; - if let Some(detail) = invariant_violation { - if !out_error.is_null() { - *out_error = - PlatformWalletFFIError::new(PlatformWalletFFIResult::ErrorNullPointer, detail); - } - return PlatformWalletFFIResult::ErrorNullPointer; - } - - // Decode mnemonic + passphrase. - let mnemonic_str = match CStr::from_ptr(mnemonic).to_str() { - Ok(s) => s, - Err(_) => { - if !out_error.is_null() { - *out_error = PlatformWalletFFIError::new( - PlatformWalletFFIResult::ErrorUtf8Conversion, - "mnemonic is not valid UTF-8", - ); - } - return PlatformWalletFFIResult::ErrorUtf8Conversion; - } - }; - let passphrase_str: &str = if passphrase.is_null() { - "" - } else { - match CStr::from_ptr(passphrase).to_str() { - Ok(s) => s, - Err(_) => { - if !out_error.is_null() { - *out_error = PlatformWalletFFIError::new( - PlatformWalletFFIResult::ErrorUtf8Conversion, - "passphrase is not valid UTF-8", - ); - } - return PlatformWalletFFIResult::ErrorUtf8Conversion; - } - } - }; - let mnemonic_obj = match Mnemonic::from_phrase(mnemonic_str, Language::English) { - Ok(m) => m, - Err(e) => { - if !out_error.is_null() { - *out_error = PlatformWalletFFIError::new( - PlatformWalletFFIResult::ErrorInvalidParameter, - format!("invalid mnemonic: {}", e), - ); - } - return PlatformWalletFFIResult::ErrorInvalidParameter; - } - }; - let seed = Zeroizing::new(mnemonic_obj.to_seed(passphrase_str)); - - // Decode the inputs array into a `(address, credits)` map. The - // SDK variant we call below resolves nonces from Platform - // itself right before submit, so no nonce travels across FFI. - let entries = slice::from_raw_parts(inputs, inputs_count); - let mut input_map: BTreeMap = BTreeMap::new(); - for entry in entries { - let address = match entry.address_type { - 0 => PlatformAddress::P2pkh(entry.hash), - 1 => PlatformAddress::P2sh(entry.hash), - _ => { - if !out_error.is_null() { - *out_error = PlatformWalletFFIError::new( - PlatformWalletFFIResult::ErrorInvalidParameter, - "invalid address_type (expected 0 or 1)", - ); - } - return PlatformWalletFFIResult::ErrorInvalidParameter; - } - }; - input_map.insert(address, entry.credits); - } - - // `output` is allowed to be null — it means "no refund, any - // residual credits stay with the new identity". Swift always - // passes a real pointer, but we keep the null branch so the - // ABI is forgiving to other callers. - let output_map = if output.is_null() { - None - } else { - let output_ref = &*output; - if output_ref.has_output { - let address = match output_ref.address_type { - 0 => PlatformAddress::P2pkh(output_ref.hash), - 1 => PlatformAddress::P2sh(output_ref.hash), - _ => { - if !out_error.is_null() { - *out_error = PlatformWalletFFIError::new( - PlatformWalletFFIResult::ErrorInvalidParameter, - "invalid output address_type (expected 0 or 1)", - ); - } - return PlatformWalletFFIResult::ErrorInvalidParameter; - } - }; - Some((address, output_ref.credits)) - } else { - None - } - }; - - PLATFORM_WALLET_STORAGE - .with_item(wallet_handle, |wallet| { - // Clone the sub-wallet facades so the heavy async work - // can be handed to a tokio worker thread (which has an - // 8 MB stack — see `runtime.rs`). Both types are `Clone` - // on top of internal `Arc`s, so this is a cheap refcount - // bump, not a deep copy. - let identity_wallet = wallet.identity().clone(); - let address_wallet = wallet.platform().clone(); - let network: Network = wallet.sdk().network; - - // ---- Derive DIP-9 auth pubkeys from the seed ------------------ - // The seed lives in `Zeroizing` until end of this closure. The - // intermediate xprivs are derived on the stack and dropped per - // iteration; they don't impl Zeroize upstream (noted). - let base_path: DerivationPath = match network { - Network::Mainnet => IDENTITY_AUTHENTICATION_PATH_MAINNET, - _ => IDENTITY_AUTHENTICATION_PATH_TESTNET, - } - .into(); - let key_type_index: u32 = KeyDerivationType::ECDSA.into(); - let secp = Secp256k1::new(); - - let master = match ExtendedPrivKey::new_master(network, &*seed) { - Ok(m) => m, - Err(e) => { - if !out_error.is_null() { - *out_error = PlatformWalletFFIError::new( - PlatformWalletFFIResult::ErrorWalletOperation, - format!("failed to derive master key from seed: {}", e), - ); - } - return PlatformWalletFFIResult::ErrorWalletOperation; - } - }; - - let mut keys_map: BTreeMap = BTreeMap::new(); - for key_index in 0..key_count { - let full_path = base_path.extend([ - ChildNumber::from_hardened_idx(key_type_index).unwrap_or_else(|_| { - ChildNumber::from_hardened_idx(0).expect("0' is always valid") - }), - match ChildNumber::from_hardened_idx(identity_index) { - Ok(cn) => cn, - Err(e) => { - if !out_error.is_null() { - *out_error = PlatformWalletFFIError::new( - PlatformWalletFFIResult::ErrorInvalidParameter, - format!("invalid identity_index: {}", e), - ); - } - return PlatformWalletFFIResult::ErrorInvalidParameter; - } - }, - match ChildNumber::from_hardened_idx(key_index) { - Ok(cn) => cn, - Err(e) => { - if !out_error.is_null() { - *out_error = PlatformWalletFFIError::new( - PlatformWalletFFIResult::ErrorInvalidParameter, - format!("invalid key_index: {}", e), - ); - } - return PlatformWalletFFIResult::ErrorInvalidParameter; - } - }, - ]); - - let ext_priv = match master.derive_priv(&secp, &full_path) { - Ok(p) => p, - Err(e) => { - if !out_error.is_null() { - *out_error = PlatformWalletFFIError::new( - PlatformWalletFFIResult::ErrorWalletOperation, - format!("failed to derive auth key at index {}: {}", key_index, e), - ); - } - return PlatformWalletFFIResult::ErrorWalletOperation; - } - }; - let ext_pub = ExtendedPubKey::from_priv(&secp, &ext_priv); - let compressed_pubkey = ext_pub.public_key.serialize(); - - let security_level = if key_index == 0 { - SecurityLevel::MASTER - } else { - SecurityLevel::HIGH - }; - - keys_map.insert( - key_index, - IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: key_index, - purpose: Purpose::AUTHENTICATION, - security_level, - contract_bounds: None, - key_type: KeyType::ECDSA_SECP256K1, - read_only: false, - data: BinaryData::new(compressed_pubkey.to_vec()), - disabled_at: None, - }), - ); - } - - // ---- Build placeholder Identity + signers --------------------- - let placeholder = Identity::V0(IdentityV0 { - id: Identifier::default(), - public_keys: keys_map, - balance: 0, - revision: 0, - }); - - let identity_signer = SeedBackedIdentitySigner::new(&*seed, network, identity_index); - let address_signer = - SeedBackedPlatformAddressSigner::new(&*seed, network, address_wallet); - - // ---- Submit --------------------------------------------------- - // `register_from_addresses` uses - // `put_with_address_funding_fetching_nonces` internally, so - // the `(address, credits)` map we pass is enough — - // Platform's current nonces are resolved at submit time. - // - // Drive the async work on a tokio worker (8 MB stack). - // Running directly on the calling thread via `block_on` - // blew the stack inside - // `verify_state_transition_was_executed_with_proof`. - // All captures are owned + `Send + 'static`. - let result = block_on_worker(async move { - identity_wallet - .register_from_addresses( - &placeholder, - input_map, - output_map, - identity_index, - &identity_signer, - &address_signer, - None, - ) - .await - }); - - match result { - Ok(identity) => { - let id_bytes: [u8; 32] = identity.id().to_buffer(); - *out_identity_id = id_bytes; - - // Wrap the new identity in a ManagedIdentity and - // hand the caller a handle into the global FFI - // storage. The wallet's IdentityManager keeps its - // own copy — this is a convenience reference for - // the caller that avoids a follow-up lookup. - let managed = platform_wallet::ManagedIdentity::new(identity, identity_index); - let handle = MANAGED_IDENTITY_STORAGE.insert(managed); - *out_identity_handle = handle; - PlatformWalletFFIResult::Success - } - Err(e) => { - if !out_error.is_null() { - *out_error = PlatformWalletFFIError::new( - PlatformWalletFFIResult::ErrorWalletOperation, - format!("register_from_addresses failed: {}", e), - ); - } - PlatformWalletFFIResult::ErrorWalletOperation - } - } - }) - .unwrap_or_else(|| { - if !out_error.is_null() { - *out_error = PlatformWalletFFIError::new( - PlatformWalletFFIResult::ErrorInvalidHandle, - "Invalid platform-wallet handle", - ); - } - PlatformWalletFFIResult::ErrorInvalidHandle - }) -} diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs new file mode 100644 index 00000000000..69b0b124407 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs @@ -0,0 +1,224 @@ +//! Asset-lock-funded identity registration driven by an external +//! `SignerHandle`. +//! +//! Replaces the panic-prone `IdentitySigner` path on +//! [`IdentityWallet::register_identity_with_funding`](platform_wallet::IdentityWallet::register_identity_with_funding). +//! +//! The single entry point — +//! [`platform_wallet_register_identity_with_funding_signer`] — wraps +//! [`IdentityWallet::register_identity_with_funding_external_signer`](platform_wallet::IdentityWallet::register_identity_with_funding_external_signer) +//! and works the same way the address-funded +//! `platform_wallet_register_identity_with_signer` does: +//! +//! - Caller pre-derives the new identity's authentication public keys +//! via [`crate::dash_sdk_derive_identity_keys_from_mnemonic`] (which +//! works for watch-only wallets, unlike the wallet-handle variant) +//! and ships them in as a flat [`crate::IdentityPubkeyFFI`] array. +//! - Caller pre-persists each key's matching private material to +//! whatever store the supplied `signer_handle` reads from (iOS +//! Keychain in the typical case). +//! - The asset-lock proof + private key are still built on the Rust +//! side from `amount_duffs` (the wallet must have spendable Core +//! UTXOs — same precondition as the legacy variant). +//! - Every IdentityCreate state-transition signature crosses the FFI +//! through `signer_handle`. +//! +//! The two-signer split that +//! `platform_wallet_register_identity_with_signer` (address-funded) +//! uses isn't needed here: the funding side is a Core-chain asset +//! lock signed with `asset_lock_private_key` (built Rust-side), not a +//! `Signer` operation. + +use std::collections::BTreeMap; +use std::convert::TryFrom; +use std::slice; + +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use platform_wallet::wallet::identity::types::funding::IdentityFundingMethod; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; + +use crate::error::*; +use crate::handle::*; +use crate::identity_registration_with_signer::IdentityPubkeyFFI; +use crate::runtime::block_on_worker; + +/// Register a new asset-lock-funded identity using an external signer. +/// +/// `amount_duffs` funds the asset lock from wallet UTXOs; the SDK +/// builds + broadcasts the asset-lock transaction internally and +/// retries with a ChainLock proof on InstantSend rejection (see +/// [`IdentityWallet::register_identity_with_funding_external_signer`](platform_wallet::IdentityWallet::register_identity_with_funding_external_signer) +/// for the IS->CL fallback details). +/// +/// On success `out_identity_id` (32 bytes) and `out_identity_handle` +/// (a fresh `ManagedIdentity` handle in `MANAGED_IDENTITY_STORAGE`) +/// are populated. +/// +/// # Safety +/// - `wallet_handle` must come from the platform-wallet handle registry. +/// - `identity_pubkeys` must point at a valid `[IdentityPubkeyFFI; +/// identity_pubkeys_count]` array, and each row's `pubkey_bytes` +/// must be a valid `[u8; pubkey_len]` buffer for the duration of +/// the call. +/// - `signer_handle` must be a valid, non-destroyed handle produced by +/// `dash_sdk_signer_create_with_ctx`. Caller retains ownership. +/// - `out_identity_id` and `out_identity_handle` must be writable for +/// the duration of the call. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_register_identity_with_funding_signer( + wallet_handle: Handle, + amount_duffs: u64, + identity_index: u32, + identity_pubkeys: *const IdentityPubkeyFFI, + identity_pubkeys_count: usize, + signer_handle: *mut SignerHandle, + out_identity_id: *mut [u8; 32], + out_identity_handle: *mut Handle, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + if signer_handle.is_null() + || identity_pubkeys.is_null() + || identity_pubkeys_count == 0 + || out_identity_id.is_null() + || out_identity_handle.is_null() + { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "signer_handle, identity_pubkeys, out_identity_id, or out_identity_handle is null/empty", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + + // Materialize identity pubkeys into a BTreeMap + // before entering the wallet-storage closure so a parse failure is + // not gated on whether the wallet handle happens to be valid. + let pubkey_rows: &[IdentityPubkeyFFI] = + slice::from_raw_parts(identity_pubkeys, identity_pubkeys_count); + let mut keys_map: BTreeMap = BTreeMap::new(); + for (i, row) in pubkey_rows.iter().enumerate() { + let key_type = match KeyType::try_from(row.key_type) { + Ok(kt) => kt, + Err(_) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!( + "identity_pubkeys[{}].key_type = {} is not a valid KeyType", + i, row.key_type + ), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + let purpose = match Purpose::try_from(row.purpose) { + Ok(p) => p, + Err(_) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!( + "identity_pubkeys[{}].purpose = {} is not a valid Purpose", + i, row.purpose + ), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + let security_level = match SecurityLevel::try_from(row.security_level) { + Ok(sl) => sl, + Err(_) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!( + "identity_pubkeys[{}].security_level = {} is not a valid SecurityLevel", + i, row.security_level + ), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + if row.pubkey_bytes.is_null() || row.pubkey_len == 0 { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + format!("identity_pubkeys[{}].pubkey_bytes is null or empty", i), + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + let pubkey_bytes: Vec = + slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec(); + keys_map.insert( + row.key_id, + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: row.key_id, + purpose, + security_level, + contract_bounds: None, + key_type, + read_only: row.read_only, + data: BinaryData::new(pubkey_bytes), + disabled_at: None, + }), + ); + } + + let signer_addr = signer_handle as usize; + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + + let result = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity_wallet + .register_identity_with_funding_external_signer( + IdentityFundingMethod::FundWithWallet { amount_duffs }, + identity_index, + keys_map, + signer, + None, + ) + .await + }); + + match result { + Ok(identity) => { + let id_bytes: [u8; 32] = identity.id().to_buffer(); + *out_identity_id = id_bytes; + let managed = platform_wallet::ManagedIdentity::new(identity, identity_index); + let handle = MANAGED_IDENTITY_STORAGE.insert(managed); + *out_identity_handle = handle; + PlatformWalletFFIResult::Success + } + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!("register_identity_with_funding_signer failed: {}", e), + ); + } + PlatformWalletFFIResult::ErrorWalletOperation + } + } + }) + .unwrap_or_else(|| { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid platform-wallet handle", + ); + } + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs new file mode 100644 index 00000000000..278fb2d1add --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs @@ -0,0 +1,692 @@ +//! Address-funded identity registration driven by external `SignerHandle`s. +//! +//! The single entry point in this module — `platform_wallet_register_identity_with_signer` +//! — replaces the legacy mnemonic-driven FFI. Instead of taking a +//! BIP-39 mnemonic across the FFI, the caller supplies: +//! +//! - the wallet handle (used for network lookup, Platform-address +//! state, and the SDK call — *not* for identity-key derivation), +//! - the already-derived identity authentication public keys +//! ([`IdentityPubkeyFFI`] rows). Earlier revisions derived these +//! on the Rust side via the wallet handle, but that path fails on +//! watch-only wallets restored from Swift-side persisted state +//! (the seed lives in iOS Keychain, not in the in-process +//! `WalletManager`). Swift now derives the pubkeys via +//! [`crate::dash_sdk_derive_identity_keys_from_mnemonic`] and +//! threads them through this FFI, which works for every wallet +//! shape regardless of how it was loaded into the process. +//! - **two** [`SignerHandle`]s (typically two views of the same +//! Swift-side `KeychainSigner`): +//! - `signer_identity_handle` — used as `Signer` +//! for the new identity's state-transition signatures. +//! - `signer_address_handle` — used as `Signer` +//! for each input platform address's funding-contribution +//! signature. +//! +//! The two-signer split is what unlocks **watch-only** wallets — the +//! wallet's own seed never needs to participate in signing on this +//! path. For wallets where the same backing store fulfils both roles +//! (the common iOS case) the caller passes the same +//! `KeychainSigner.handle` for both arguments and the trampoline +//! dispatches by `key_type` (KeyType discriminant 0–4 → identity-key +//! lookup; `0xFF` → platform-address-hash lookup). +//! +//! The Swift caller is responsible for: +//! 1. Calling [`crate::platform_wallet_preview_identity_registration_keys`] +//! (or deriving via the existing `key_wallet_*` FFI) to obtain the +//! `(pubkey, derivation_path)` pairs for the new identity's keys, AND +//! 2. Persisting those pairs to SwiftData / Keychain so the +//! identity `KeychainSigner` can later look up the matching private +//! keys. +//! +//! Platform-address private keys are NOT pre-persisted. The address +//! signer trampoline derives them on demand per signing call from +//! `(mnemonic-in-Keychain, derivation-path-in-SwiftData)` via +//! `dash_sdk_sign_with_mnemonic_and_path` and zeroes the buffer +//! immediately. See `KeychainSigner.swift`. +//! +//! After this call completes, every state-transition signature +//! (identity AND platform-address) crosses the FFI boundary via the +//! supplied `SignerHandle`s rather than via a Rust-derived seed. +//! +//! See `swift-sdk/CLAUDE.md` for the architectural reasoning behind +//! pushing the seed off the FFI boundary. + +use dashcore::PrivateKey as DashPrivateKey; +use dpp::address_funds::PlatformAddress; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::v0::IdentityV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use dpp::prelude::Identifier; +use platform_wallet::derive_identity_auth_keypair; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; +use std::collections::BTreeMap; +use std::convert::TryFrom; +use std::ffi::CString; +use std::ptr; +use std::slice; +use zeroize::Zeroize; + +use crate::error::*; +use crate::handle::*; +use crate::identity_key_preview::IdentityKeyPreviewFFI; +use crate::identity_registration::{IdentityFundingInputFFI, IdentityFundingOutputFFI}; +use crate::runtime::block_on_worker; + +/// One identity authentication public key the caller has already +/// derived (via [`crate::dash_sdk_derive_identity_keys_from_mnemonic`] +/// or equivalent) and now wants the placeholder identity to be built +/// from. Mirrored on the Swift side as `IdentityPubkeyFFI`. +/// +/// Field discriminants match the DPP enum repr(u8) layout exactly: +/// - `key_type`: [`KeyType`] discriminant (0 = ECDSA_SECP256K1, etc.). +/// - `purpose`: [`Purpose`] discriminant (0 = AUTHENTICATION, etc.). +/// - `security_level`: [`SecurityLevel`] discriminant (0 = MASTER, +/// 1 = CRITICAL, 2 = HIGH, 3 = MEDIUM). +/// +/// `pubkey_bytes` is borrowed by the FFI for the duration of the call; +/// the caller retains ownership. Compressed secp256k1 pubkeys are +/// always 33 bytes (`pubkey_len == 33`); BLS would be 48; etc. +#[repr(C)] +pub struct IdentityPubkeyFFI { + pub key_id: u32, + pub key_type: u8, + pub purpose: u8, + pub security_level: u8, + pub pubkey_bytes: *const u8, + pub pubkey_len: usize, + pub read_only: bool, +} + +/// Register a new identity funded by Platform-address balances, using +/// **two** external [`SignerHandle`]s — one for the new identity's +/// state-transition keys, one for the input platform addresses. +/// +/// Replaces the deleted mnemonic-driven `platform_wallet_register_identity_from_addresses`: +/// - No mnemonic / passphrase parameters. +/// - The new identity's authentication pubkeys are now passed in by +/// the caller as a [`IdentityPubkeyFFI`] array (previously this +/// function derived them via the wallet handle, which fails for +/// watch-only wallets where the seed lives in iOS Keychain rather +/// than the in-process `WalletManager`). Swift derives them via +/// [`crate::dash_sdk_derive_identity_keys_from_mnemonic`] and +/// threads them through here. +/// - Every state-transition signature crosses the FFI through one of +/// the supplied signer handles. +/// - `signer_identity_handle` and `signer_address_handle` are +/// `*mut SignerHandle`s produced by `dash_sdk_signer_create_with_ctx` +/// (typically two views of the same Swift `KeychainSigner`). The +/// caller retains ownership of both; this function does NOT +/// destroy them. +/// +/// The two-signer split keeps the FFI explicit about both signing +/// roles even when most callers pass the same handle twice — it +/// unblocks watch-only wallets (where the identity signer is a +/// hardware HSM and the address signer reaches into the Keychain) +/// and Keychain-backed platform-address keys without another ABI +/// change later. +/// +/// On success both `out_identity_id` (32 bytes) and +/// `out_identity_handle` are populated. The returned handle points at +/// a freshly-inserted `ManagedIdentity` in `MANAGED_IDENTITY_STORAGE` +/// wrapping the new identity together with `identity_index`. +/// +/// # Safety +/// - All pointer parameters follow the same null / lifetime rules as +/// the mnemonic-based variant. +/// - `identity_pubkeys` must point at a valid `[IdentityPubkeyFFI; +/// identity_pubkeys_count]` array, and each row's `pubkey_bytes` +/// must be a valid `[u8; pubkey_len]` buffer for the duration of +/// the call. The caller retains ownership of every buffer. +/// - `signer_identity_handle` and `signer_address_handle` must each be +/// a valid, non-destroyed handle and must outlive this call. Passing +/// the same pointer for both is supported and expected — the +/// underlying `VTableSigner` impls `Signer` AND +/// `Signer` and dispatches by `key_type` byte. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_register_identity_with_signer( + wallet_handle: Handle, + identity_index: u32, + identity_pubkeys: *const IdentityPubkeyFFI, + identity_pubkeys_count: usize, + signer_identity_handle: *mut SignerHandle, + signer_address_handle: *mut SignerHandle, + inputs: *const IdentityFundingInputFFI, + inputs_count: usize, + output: *const IdentityFundingOutputFFI, + out_identity_id: *mut [u8; 32], + out_identity_handle: *mut Handle, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + let invariant_violation: Option<&'static str> = if inputs.is_null() { + Some("`inputs` pointer is null") + } else if inputs_count == 0 { + Some("`inputs_count` is zero") + } else if identity_pubkeys.is_null() { + Some("`identity_pubkeys` pointer is null") + } else if identity_pubkeys_count == 0 { + Some("`identity_pubkeys_count` must be >= 1") + } else if signer_identity_handle.is_null() { + Some("`signer_identity_handle` pointer is null") + } else if signer_address_handle.is_null() { + Some("`signer_address_handle` pointer is null") + } else if out_identity_id.is_null() { + Some("`out_identity_id` pointer is null") + } else if out_identity_handle.is_null() { + Some("`out_identity_handle` pointer is null") + } else { + None + }; + if let Some(detail) = invariant_violation { + if !out_error.is_null() { + *out_error = + PlatformWalletFFIError::new(PlatformWalletFFIResult::ErrorNullPointer, detail); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + + // Parse the inputs and optional output exactly the same way the + // mnemonic variant does. Keeping the FFI shape identical lets + // Swift call this function by swapping the symbol name and + // dropping the mnemonic + passphrase arguments. + let entries = slice::from_raw_parts(inputs, inputs_count); + let mut input_map: BTreeMap = BTreeMap::new(); + for entry in entries { + let address = match entry.address_type { + 0 => PlatformAddress::P2pkh(entry.hash), + 1 => PlatformAddress::P2sh(entry.hash), + _ => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + "invalid address_type (expected 0 or 1)", + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + input_map.insert(address, entry.credits); + } + + let output_map = if output.is_null() { + None + } else { + let output_ref = &*output; + if output_ref.has_output { + let address = match output_ref.address_type { + 0 => PlatformAddress::P2pkh(output_ref.hash), + 1 => PlatformAddress::P2sh(output_ref.hash), + _ => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + "invalid output address_type (expected 0 or 1)", + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + Some((address, output_ref.credits)) + } else { + None + } + }; + + // Re-acquire each `VTableSigner` behind its handle as a borrowed + // reference inside the future. Round-tripping the pointers through + // `usize` gives the spawned future a `Send + 'static` capture (the + // raw pointer is `!Send`, but `usize` is). The actual signer state + // — `Inner::Callback { ctx, vtable }` — is `Send + Sync` (see the + // unsafe impls in `rs-sdk-ffi/src/signer.rs`). + // + // The two pointers may legitimately alias when the caller is + // sharing one `KeychainSigner` for both roles; the `Signer` + // trait is generic over `K`, so the same `VTableSigner` value is + // viewed as `Signer` *or* `Signer` + // at the call site below depending on which generic parameter + // `register_from_addresses` instantiates. + let signer_identity_addr = signer_identity_handle as usize; + let signer_address_addr = signer_address_handle as usize; + + // Materialize the caller-supplied pubkey rows into a BTreeMap of + // `IdentityPublicKey` once, *before* entering the wallet-storage + // closure. This lookup is independent of the wallet handle (we no + // longer derive from the seed here — Swift derived these via the + // mnemonic-driven FFI which works for watch-only wallets too) and + // a parse failure should not depend on whether the wallet handle + // happens to be valid. Validation errors propagate out the same + // way as before via `out_error`. + let pubkey_rows: &[IdentityPubkeyFFI] = + slice::from_raw_parts(identity_pubkeys, identity_pubkeys_count); + let mut keys_map: BTreeMap = BTreeMap::new(); + for (i, row) in pubkey_rows.iter().enumerate() { + let key_type = match KeyType::try_from(row.key_type) { + Ok(kt) => kt, + Err(_) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!( + "identity_pubkeys[{}].key_type = {} is not a valid KeyType discriminant", + i, row.key_type + ), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + let purpose = match Purpose::try_from(row.purpose) { + Ok(p) => p, + Err(_) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!( + "identity_pubkeys[{}].purpose = {} is not a valid Purpose discriminant", + i, row.purpose + ), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + let security_level = match SecurityLevel::try_from(row.security_level) { + Ok(sl) => sl, + Err(_) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!( + "identity_pubkeys[{}].security_level = {} is not a valid SecurityLevel discriminant", + i, row.security_level + ), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + if row.pubkey_bytes.is_null() || row.pubkey_len == 0 { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + format!("identity_pubkeys[{}].pubkey_bytes is null or empty", i), + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + let pubkey_bytes: Vec = + slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec(); + keys_map.insert( + row.key_id, + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: row.key_id, + purpose, + security_level, + contract_bounds: None, + key_type, + read_only: row.read_only, + data: BinaryData::new(pubkey_bytes), + disabled_at: None, + }), + ); + } + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + + // Pubkey derivation moved up above the wallet-storage + // closure: the caller supplies the pubkeys directly via + // `identity_pubkeys`, so we no longer need to consult the + // wallet manager here. The wallet handle is still + // required for `wallet.identity()` and for the SDK call + // below — those uses are unchanged. + let placeholder = Identity::V0(IdentityV0 { + id: Identifier::default(), + public_keys: keys_map, + balance: 0, + revision: 0, + }); + + // SAFETY: the caller guaranteed both signer handles are + // valid and outlive this call. `signer_*_addr` are the + // same pointers reinterpreted as `usize` so they can + // travel into the `'static + Send` future below. + let result = block_on_worker(async move { + let identity_signer: &VTableSigner = + unsafe { &*(signer_identity_addr as *const VTableSigner) }; + let address_signer: &VTableSigner = + unsafe { &*(signer_address_addr as *const VTableSigner) }; + + identity_wallet + .register_from_addresses( + &placeholder, + input_map, + output_map, + identity_index, + identity_signer, + address_signer, + None, + ) + .await + }); + + match result { + Ok(identity) => { + let id_bytes: [u8; 32] = identity.id().to_buffer(); + *out_identity_id = id_bytes; + let managed = platform_wallet::ManagedIdentity::new(identity, identity_index); + let handle = MANAGED_IDENTITY_STORAGE.insert(managed); + *out_identity_handle = handle; + PlatformWalletFFIResult::Success + } + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!("register_from_addresses failed: {}", e), + ); + } + PlatformWalletFFIResult::ErrorWalletOperation + } + } + }) + .unwrap_or_else(|| { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid platform-wallet handle", + ); + } + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} + +// --------------------------------------------------------------------------- +// Pre-registration key derivation +// --------------------------------------------------------------------------- + +/// Heap-allocated array of [`IdentityKeyPreviewFFI`] rows handed back +/// by [`platform_wallet_derive_identity_keys_for_index`]. Same memory +/// layout (and same free function) as [`crate::identity_key_preview::IdentityKeyPreviewsFFI`] +/// so the existing release machinery can reclaim it. +/// +/// We deliberately re-use `IdentityKeyPreviewFFI` rather than inventing +/// a new row type so the Swift side can drop the result through the +/// existing `previewIdentityRegistrationKeys`-style marshalling code +/// (just iterated over a different `(identity_index, key_index)` set). +#[repr(C)] +pub struct IdentityRegistrationKeyDerivationsFFI { + pub items: *mut IdentityKeyPreviewFFI, + pub count: usize, +} + +impl IdentityRegistrationKeyDerivationsFFI { + fn empty() -> Self { + Self { + items: ptr::null_mut(), + count: 0, + } + } +} + +/// Derive every authentication-key pair the upcoming +/// [`platform_wallet_register_identity_with_signer`] call will need +/// for `identity_index`, returning one row per key id in `0..key_count`. +/// +/// Sister function to [`crate::platform_wallet_preview_identity_registration_keys`]: +/// the preview only walks the MASTER slot at key_id 0 across many +/// `identity_index` values; this function fixes the `identity_index` +/// and walks `key_count` consecutive `key_id`s. Used at registration +/// time so the Swift `KeychainSigner` can pre-stash every key the +/// signing pass will ask for. +/// +/// On success the array is owned by Rust and must be released via +/// [`platform_wallet_derive_identity_keys_for_index_free`]. On error +/// the struct is left at its zero state. +/// +/// # Superseded — prefer [`crate::dash_sdk_derive_identity_keys_from_mnemonic`] +/// +/// This entry point fails with `"Cannot derive private keys from +/// watch-only wallet"` for wallets restored from Swift-side persisted +/// state (where the seed lives in iOS Keychain rather than in the +/// in-process `WalletManager`). Most call sites should prefer +/// [`crate::dash_sdk_derive_identity_keys_from_mnemonic`], which +/// takes the mnemonic directly and works on every wallet shape +/// regardless of how it was loaded into the process. +/// +/// Kept around for any out-of-tree consumer that still binds to the +/// old symbol; new code should not call it. +/// +/// # Safety +/// `wallet_handle` must come from the platform-wallet handle registry. +/// `out_rows` must be a valid, writable pointer. `out_error` may be +/// null. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_derive_identity_keys_for_index( + wallet_handle: Handle, + identity_index: u32, + key_count: u32, + out_rows: *mut IdentityRegistrationKeyDerivationsFFI, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + if out_rows.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "out_rows is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + *out_rows = IdentityRegistrationKeyDerivationsFFI::empty(); + if key_count == 0 { + return PlatformWalletFFIResult::Success; + } + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let wm = wallet.wallet_manager().blocking_read(); + let wallet_id = wallet.wallet_id(); + let key_wallet = match wm.get_wallet(&wallet_id) { + Some(w) => w, + None => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Wallet not found in wallet manager", + ); + } + return PlatformWalletFFIResult::ErrorInvalidHandle; + } + }; + let network = key_wallet.network; + + let mut rows: Vec = Vec::with_capacity(key_count as usize); + + // Hand-roll cleanup on failure: each successfully-pushed + // row owns CString / Vec allocations that won't be freed + // by `Vec::drop` (we declare them as raw pointers). + let cleanup = |rows: Vec| { + for row in rows { + if !row.derivation_path.is_null() { + let _ = CString::from_raw(row.derivation_path); + } + if !row.public_key.is_null() { + let _ = Vec::from_raw_parts( + row.public_key, + row.public_key_len, + row.public_key_len, + ); + } + if !row.private_key_wif.is_null() { + let _ = CString::from_raw(row.private_key_wif); + } + } + }; + + for key_id in 0..key_count { + let (path, ext_priv, public_key) = + match derive_identity_auth_keypair(key_wallet, network, identity_index, key_id) + { + Ok(t) => t, + Err(e) => { + cleanup(rows); + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!( + "derive_identity_keys_for_index: derivation failed at \ + (identity={}, key={}): {}", + identity_index, key_id, e + ), + ); + } + return PlatformWalletFFIResult::ErrorWalletOperation; + } + }; + + let path_cstring = match CString::new(path.to_string()) { + Ok(s) => s, + Err(e) => { + cleanup(rows); + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorUtf8Conversion, + format!("derivation path contained NUL byte: {}", e), + ); + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + }; + + let pub_bytes: [u8; 33] = public_key.serialize(); + let mut pub_box: Box<[u8]> = pub_bytes.to_vec().into_boxed_slice(); + let pub_ptr = pub_box.as_mut_ptr(); + let pub_len = pub_box.len(); + std::mem::forget(pub_box); + + let dash_private = DashPrivateKey { + compressed: true, + network, + inner: ext_priv.private_key, + }; + let wif_cstring = match CString::new(dash_private.to_wif()) { + Ok(s) => s, + Err(e) => { + unsafe { + drop(Vec::from_raw_parts(pub_ptr, pub_len, pub_len)); + } + drop(path_cstring); + cleanup(rows); + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorUtf8Conversion, + format!("WIF string contained NUL byte: {}", e), + ); + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + }; + + rows.push(IdentityKeyPreviewFFI { + identity_index, + derivation_path: path_cstring.into_raw(), + public_key: pub_ptr, + public_key_len: pub_len, + private_key_wif: wif_cstring.into_raw(), + private_key_bytes: ext_priv.private_key.secret_bytes(), + }); + } + + let mut boxed = rows.into_boxed_slice(); + let items_ptr = boxed.as_mut_ptr(); + let items_count = boxed.len(); + std::mem::forget(boxed); + + *out_rows = IdentityRegistrationKeyDerivationsFFI { + items: items_ptr, + count: items_count, + }; + PlatformWalletFFIResult::Success + }) + .unwrap_or_else(|| { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid platform-wallet handle", + ); + } + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} + +/// Release a [`IdentityRegistrationKeyDerivationsFFI`] previously +/// populated by [`platform_wallet_derive_identity_keys_for_index`]. +/// +/// Safe to call on a zero / null struct or null outer pointer (no-op). +/// Each row's owned strings (`derivation_path`, `private_key_wif`) +/// and pubkey buffer are reclaimed. +/// +/// # Safety +/// `rows.items` must have been handed out by +/// [`platform_wallet_derive_identity_keys_for_index`] and must not be +/// freed twice. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_derive_identity_keys_for_index_free( + rows: *mut IdentityRegistrationKeyDerivationsFFI, +) { + if rows.is_null() { + return; + } + let owned = std::mem::replace(&mut *rows, IdentityRegistrationKeyDerivationsFFI::empty()); + if owned.items.is_null() || owned.count == 0 { + return; + } + let slice = std::slice::from_raw_parts_mut(owned.items, owned.count); + for row in slice.iter_mut() { + if !row.derivation_path.is_null() { + let _ = CString::from_raw(row.derivation_path); + } + if !row.public_key.is_null() { + let _ = Vec::from_raw_parts(row.public_key, row.public_key_len, row.public_key_len); + } + if !row.private_key_wif.is_null() { + // The WIF string encodes the same 32-byte secret as + // `private_key_bytes`; scrub the buffer in place before + // dropping so the heap allocation isn't released with + // recoverable key material. + let mut wif = CString::from_raw(row.private_key_wif).into_bytes_with_nul(); + wif.zeroize(); + row.private_key_wif = ptr::null_mut(); + } + // Final inline secret scalar — wipe before the row slab is + // returned to the allocator. + row.private_key_bytes.zeroize(); + } + // Reclaim the row array itself. + let _ = Box::from_raw(slice as *mut [IdentityKeyPreviewFFI]); +} + +// --------------------------------------------------------------------------- +// Pre-registration platform-address private-key derivation — DELETED +// --------------------------------------------------------------------------- +// +// The previous design pre-derived platform-address private keys in Rust, +// shipped them across the FFI as 32-byte scalars, and had Swift persist +// them in the Keychain keyed by 20-byte address hash. That violated the +// "platform-address private keys are NEVER persisted" rule: those keys +// are pure derivation outputs of `(mnemonic, path)` and exist only for +// the duration of one signing call. +// +// The replacement path lives in `rs-sdk-ffi` as +// `dash_sdk_sign_with_mnemonic_and_path`: the Swift `KeychainSigner` +// trampoline pulls the mnemonic from Keychain on the 0xFF branch, +// looks up the derivation path on the matching `PersistentPlatformAddress` +// SwiftData row, and calls that one-shot FFI to produce a signature. +// The derived key never leaves Rust, never crosses the FFI as bytes, +// and never lands in the Keychain. diff --git a/packages/rs-platform-wallet-ffi/src/identity_transfer.rs b/packages/rs-platform-wallet-ffi/src/identity_transfer.rs new file mode 100644 index 00000000000..1bc3e9a88bd --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/identity_transfer.rs @@ -0,0 +1,266 @@ +//! FFI bindings for identity → identity credit transfer + identity → +//! platform-address credit transfer driven by an external `SignerHandle`. +//! +//! Replaces the panic-prone `IdentitySigner` path on +//! [`IdentityWallet::transfer_credits`](platform_wallet::IdentityWallet::transfer_credits). +//! Every identity-state-transition signature crosses the FFI through +//! the supplied `signer_handle` (typically the iOS-side `KeychainSigner`), +//! so the wallet's own seed never participates Rust-side. This unblocks +//! watch-only wallets and avoids the inner-lock deadlock the legacy path +//! hit when its derivation tried to `blocking_read` the wallet manager +//! from inside a Tokio worker. +//! +//! Entry points: +//! - [`platform_wallet_transfer_credits_with_signer`] — identity → identity +//! transfer. +//! - [`platform_wallet_transfer_credits_to_addresses_with_signer`] — +//! identity → platform addresses transfer (1+ recipients). + +use std::collections::BTreeMap; +use std::slice; + +use dpp::address_funds::PlatformAddress; +use dpp::fee::Credits; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; + +use crate::error::*; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::types::*; + +/// One recipient of a credit transfer-to-addresses call. +/// +/// Mirrors the platform-address-side `AddressBalanceEntryFFI` shape +/// but stripped down — only `address_type`, `hash`, and `credits` are +/// needed for the identity-side transfer (no nonce; the SDK fetches +/// it). `address_type` discriminates: `0 = P2PKH`, `1 = P2SH`. +#[repr(C)] +pub struct PlatformAddressCreditOutputFFI { + pub address_type: u8, + pub hash: [u8; 20], + pub credits: u64, +} + +/// Transfer `amount` credits from `from_identity_id` to +/// `to_identity_id` using the supplied `signer_handle` for the +/// identity-state-transition signature. +/// +/// Wraps +/// [`IdentityWallet::transfer_credits_with_external_signer`](platform_wallet::IdentityWallet::transfer_credits_with_external_signer). +/// On success the sender's local balance on `ManagedIdentity` is +/// updated and a snapshot changeset is emitted via the persister so +/// the Swift `PersistentIdentity` row refreshes through the +/// `on_persist_identities_fn` callback. +/// +/// # Safety +/// - `wallet_handle` must come from the platform-wallet handle registry. +/// - `from_identity_id` / `to_identity_id` must each point at a 32-byte +/// buffer for the duration of the call. +/// - `signer_handle` must be a valid, non-destroyed handle produced by +/// `dash_sdk_signer_create_with_ctx` (typically `KeychainSigner.handle`). +/// Caller retains ownership; this function does NOT destroy it. +/// - `out_error` may be null only when the caller is willing to lose +/// the diagnostic message. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_transfer_credits_with_signer( + wallet_handle: Handle, + from_identity_id: *const u8, + to_identity_id: *const u8, + amount: u64, + signer_handle: *mut SignerHandle, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + if signer_handle.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "signer_handle is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + + let from_id = match read_identifier(from_identity_id) { + Ok(i) => i, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidIdentifier, + format!("Invalid from_identity_id: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorInvalidIdentifier; + } + }; + let to_id = match read_identifier(to_identity_id) { + Ok(i) => i, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidIdentifier, + format!("Invalid to_identity_id: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorInvalidIdentifier; + } + }; + + // Round-trip the signer pointer through `usize` so the spawned + // future has a `Send + 'static` capture (raw pointers are `!Send`, + // but `usize` is). The `VTableSigner`'s `Inner::Callback { ctx, + // vtable }` is `Send + Sync` (see the unsafe impls in + // `rs-sdk-ffi/src/signer.rs`). + let signer_addr = signer_handle as usize; + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let result = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity_wallet + .transfer_credits_with_external_signer(&from_id, &to_id, amount, signer, None) + .await + }); + match result { + Ok(()) => PlatformWalletFFIResult::Success, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!("transfer_credits_with_signer failed: {e}"), + ); + } + PlatformWalletFFIResult::ErrorWalletOperation + } + } + }) + .unwrap_or_else(|| { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid platform-wallet handle", + ); + } + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} + +/// Transfer credits from `from_identity_id` to a set of +/// [`PlatformAddressCreditOutputFFI`] recipients using the supplied +/// `signer_handle`. +/// +/// Wraps +/// [`IdentityWallet::transfer_credits_to_addresses_with_external_signer`](platform_wallet::IdentityWallet::transfer_credits_to_addresses_with_external_signer). +/// +/// `out_new_balance` (when non-null) receives the sender's remaining +/// balance after the transfer. +/// +/// # Safety +/// Same null/lifetime rules as +/// [`platform_wallet_transfer_credits_with_signer`]. Additionally +/// `outputs` must point at a valid `[PlatformAddressCreditOutputFFI; +/// outputs_count]` array for the duration of the call (caller retains +/// ownership of the underlying buffers). +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_transfer_credits_to_addresses_with_signer( + wallet_handle: Handle, + from_identity_id: *const u8, + outputs: *const PlatformAddressCreditOutputFFI, + outputs_count: usize, + signer_handle: *mut SignerHandle, + out_new_balance: *mut u64, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + if signer_handle.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "signer_handle is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + if outputs.is_null() || outputs_count == 0 { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "outputs is null or empty", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + + let from_id = match read_identifier(from_identity_id) { + Ok(i) => i, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidIdentifier, + format!("Invalid from_identity_id: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorInvalidIdentifier; + } + }; + + let entries = slice::from_raw_parts(outputs, outputs_count); + let mut output_map: BTreeMap = BTreeMap::new(); + for entry in entries { + let address = match entry.address_type { + 0 => PlatformAddress::P2pkh(entry.hash), + 1 => PlatformAddress::P2sh(entry.hash), + _ => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + "invalid address_type (expected 0 or 1)", + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + output_map.insert(address, entry.credits); + } + + let signer_addr = signer_handle as usize; + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let result = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity_wallet + .transfer_credits_to_addresses_with_external_signer( + &from_id, output_map, signer, None, + ) + .await + }); + match result { + Ok(new_balance) => { + if !out_new_balance.is_null() { + *out_new_balance = new_balance; + } + PlatformWalletFFIResult::Success + } + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!("transfer_credits_to_addresses_with_signer failed: {e}"), + ); + } + PlatformWalletFFIResult::ErrorWalletOperation + } + } + }) + .unwrap_or_else(|| { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid platform-wallet handle", + ); + } + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs new file mode 100644 index 00000000000..960ffea15f1 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -0,0 +1,208 @@ +//! FFI bindings for identity update (add / disable keys) driven by an +//! external `SignerHandle`. +//! +//! Replaces the panic-prone `IdentitySigner` path on +//! [`IdentityWallet::update_identity`](platform_wallet::IdentityWallet::update_identity). +//! The MASTER auth key signs the `IdentityUpdateTransition` via the +//! supplied `signer_handle` (typically the iOS-side `KeychainSigner`). + +use std::convert::TryFrom; +use std::slice; + +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; + +use crate::error::*; +use crate::handle::*; +use crate::identity_registration_with_signer::IdentityPubkeyFFI; +use crate::runtime::block_on_worker; +use crate::types::*; + +/// Update an identity by adding new public keys and/or disabling +/// existing key IDs, signing the resulting `IdentityUpdateTransition` +/// with the supplied `signer_handle`. +/// +/// The new keys are passed in as flat [`IdentityPubkeyFFI`] rows +/// (mirroring the registration FFI). Caller is responsible for +/// pre-persisting each new key's private material to whatever store +/// the signer reads from (iOS Keychain in the typical case) so the +/// signer can later sign with the newly-added keys; the signer here +/// only signs the update transition itself with an existing MASTER +/// key. +/// +/// Wraps +/// [`IdentityWallet::update_identity_with_external_signer`](platform_wallet::IdentityWallet::update_identity_with_external_signer). +/// +/// # Safety +/// - `wallet_handle` must come from the platform-wallet handle registry. +/// - `identity_id` must point at a 32-byte buffer for the duration of +/// the call. +/// - `add_public_keys` must point at a valid `[IdentityPubkeyFFI; +/// add_public_keys_count]` array, and each row's `pubkey_bytes` +/// must be a valid `[u8; pubkey_len]` buffer for the duration of +/// the call. Either `(null, 0)` if no keys are being added. +/// - `disable_public_key_ids` must point at a valid +/// `[u32; disable_public_key_ids_count]` array. Either `(null, 0)` +/// if no keys are being disabled. +/// - `signer_handle` must be a valid, non-destroyed handle produced by +/// `dash_sdk_signer_create_with_ctx`. Caller retains ownership. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( + wallet_handle: Handle, + identity_id: *const u8, + add_public_keys: *const IdentityPubkeyFFI, + add_public_keys_count: usize, + disable_public_key_ids: *const u32, + disable_public_key_ids_count: usize, + signer_handle: *mut SignerHandle, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + if signer_handle.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "signer_handle is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + + let id = match read_identifier(identity_id) { + Ok(i) => i, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidIdentifier, + format!("Invalid identity_id: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorInvalidIdentifier; + } + }; + + // Materialize add_public_keys into Vec before + // entering the wallet-storage closure so a parse failure is not + // gated on whether the wallet handle happens to be valid. + let add_keys: Vec = if add_public_keys.is_null() + || add_public_keys_count == 0 + { + Vec::new() + } else { + let rows: &[IdentityPubkeyFFI] = + slice::from_raw_parts(add_public_keys, add_public_keys_count); + let mut keys: Vec = Vec::with_capacity(rows.len()); + for (i, row) in rows.iter().enumerate() { + let key_type = match KeyType::try_from(row.key_type) { + Ok(kt) => kt, + Err(_) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!( + "add_public_keys[{}].key_type = {} is not a valid KeyType", + i, row.key_type + ), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + let purpose = match Purpose::try_from(row.purpose) { + Ok(p) => p, + Err(_) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!( + "add_public_keys[{}].purpose = {} is not a valid Purpose", + i, row.purpose + ), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + let security_level = match SecurityLevel::try_from(row.security_level) { + Ok(sl) => sl, + Err(_) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!( + "add_public_keys[{}].security_level = {} is not a valid SecurityLevel", + i, row.security_level + ), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + if row.pubkey_bytes.is_null() || row.pubkey_len == 0 { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + format!("add_public_keys[{}].pubkey_bytes is null or empty", i), + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + let pubkey_bytes: Vec = + slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec(); + keys.push(IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: row.key_id, + purpose, + security_level, + contract_bounds: None, + key_type, + read_only: row.read_only, + data: BinaryData::new(pubkey_bytes), + disabled_at: None, + })); + } + keys + }; + + let disable_ids: Vec = + if disable_public_key_ids.is_null() || disable_public_key_ids_count == 0 { + Vec::new() + } else { + slice::from_raw_parts(disable_public_key_ids, disable_public_key_ids_count).to_vec() + }; + + let signer_addr = signer_handle as usize; + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let result = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity_wallet + .update_identity_with_external_signer(&id, add_keys, disable_ids, signer, None) + .await + }); + match result { + Ok(()) => PlatformWalletFFIResult::Success, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!("update_identity_with_signer failed: {e}"), + ); + } + PlatformWalletFFIResult::ErrorWalletOperation + } + } + }) + .unwrap_or_else(|| { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid platform-wallet handle", + ); + } + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} diff --git a/packages/rs-platform-wallet-ffi/src/identity_withdrawal.rs b/packages/rs-platform-wallet-ffi/src/identity_withdrawal.rs new file mode 100644 index 00000000000..513c9e08d41 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/identity_withdrawal.rs @@ -0,0 +1,157 @@ +//! FFI bindings for identity → Core address withdrawal driven by an +//! external `SignerHandle`. +//! +//! Replaces the panic-prone `IdentitySigner` path on +//! [`IdentityWallet::withdraw_credits`](platform_wallet::IdentityWallet::withdraw_credits). +//! The withdrawal state-transition signature crosses the FFI through +//! the supplied `signer_handle` (typically the iOS-side `KeychainSigner`). + +use std::ffi::CStr; +use std::os::raw::c_char; +use std::str::FromStr; + +use dashcore::Address as DashAddress; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; + +use crate::error::*; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::types::*; + +/// Withdraw `amount` credits from `identity_id` to a Dash address +/// (`to_address` — `Address::from_str`-parseable, e.g. P2PKH base58 +/// like `"yNPbcFfabtNmmxKdGwhHomdYfVs6gikbPf"`) using the supplied +/// `signer_handle` for the identity-state-transition signature. +/// +/// Wraps +/// [`IdentityWallet::withdraw_credits_with_external_signer`](platform_wallet::IdentityWallet::withdraw_credits_with_external_signer). +/// On success the identity's local balance on `ManagedIdentity` is +/// updated (the Rust side performs the credit-debit) and a snapshot +/// changeset is emitted via the persister so the Swift +/// `PersistentIdentity` row refreshes. +/// +/// # Safety +/// - `wallet_handle` must come from the platform-wallet handle registry. +/// - `identity_id` must point at a 32-byte buffer for the duration of +/// the call. +/// - `to_address` must be a NUL-terminated UTF-8 C-string for the +/// duration of the call. +/// - `signer_handle` must be a valid, non-destroyed handle produced by +/// `dash_sdk_signer_create_with_ctx`. Caller retains ownership. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_withdraw_credits_with_signer( + wallet_handle: Handle, + identity_id: *const u8, + amount: u64, + to_address: *const c_char, + signer_handle: *mut SignerHandle, + out_error: *mut PlatformWalletFFIError, +) -> PlatformWalletFFIResult { + if signer_handle.is_null() || to_address.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "signer_handle or to_address is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } + + let id = match read_identifier(identity_id) { + Ok(i) => i, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidIdentifier, + format!("Invalid identity_id: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorInvalidIdentifier; + } + }; + + let to_address_str = match CStr::from_ptr(to_address).to_str() { + Ok(s) => s.to_string(), + Err(_) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorUtf8Conversion, + "to_address is not valid UTF-8", + ); + } + return PlatformWalletFFIResult::ErrorUtf8Conversion; + } + }; + // `Address::from_str` returns an unchecked address; cross-check + // it against the wallet's active network before `assume_checked` + // so a mainnet-vs-testnet mismatch fails fast at the FFI boundary + // rather than as an opaque protocol-side error. + let to_address_unchecked = match DashAddress::from_str(&to_address_str) { + Ok(a) => a, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!("Invalid Dash address: {e}"), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + + let signer_addr = signer_handle as usize; + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let wallet_network = wallet.platform().network(); + let to_address_parsed = + match to_address_unchecked.clone().require_network(wallet_network) { + Ok(addr) => addr, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidParameter, + format!( + "Address network mismatch (wallet network {wallet_network:?}): {e}" + ), + ); + } + return PlatformWalletFFIResult::ErrorInvalidParameter; + } + }; + let identity_wallet = wallet.identity().clone(); + let result = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity_wallet + .withdraw_credits_with_external_signer( + &id, + amount, + &to_address_parsed, + signer, + None, + ) + .await + }); + match result { + Ok(()) => PlatformWalletFFIResult::Success, + Err(e) => { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorWalletOperation, + format!("withdraw_credits_with_signer failed: {e}"), + ); + } + PlatformWalletFFIResult::ErrorWalletOperation + } + } + }) + .unwrap_or_else(|| { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorInvalidHandle, + "Invalid platform-wallet handle", + ); + } + PlatformWalletFFIResult::ErrorInvalidHandle + }) +} diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 47923c544f6..f8e24af37c8 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -25,9 +25,15 @@ pub mod event_handler; pub mod handle; pub mod identity_discovery; pub mod identity_key_preview; +pub mod identity_keys_from_mnemonic; pub mod identity_manager; pub mod identity_persistence; pub mod identity_registration; +pub mod identity_registration_funded_with_signer; +pub mod identity_registration_with_signer; +pub mod identity_transfer; +pub mod identity_update; +pub mod identity_withdrawal; pub mod managed_identity; pub mod manager; pub mod memory_explorer; @@ -61,8 +67,14 @@ pub use event_handler::*; pub use handle::*; pub use identity_discovery::*; pub use identity_key_preview::*; +pub use identity_keys_from_mnemonic::*; pub use identity_manager::*; pub use identity_persistence::*; +pub use identity_registration_funded_with_signer::*; +pub use identity_registration_with_signer::*; +pub use identity_transfer::*; +pub use identity_update::*; +pub use identity_withdrawal::*; pub use managed_identity::*; pub use manager::*; pub use memory_explorer::*; diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 5bfb8d1c84f..cb7715e609e 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -35,13 +35,15 @@ use crate::identity_persistence::{ }; use crate::platform_address_types::AddressBalanceEntryFFI; use crate::wallet_restore_types::{ - AccountSpecFFI, AccountTypeTagFFI, IdentityRestoreEntryFFI, LoadWalletListFn, - LoadWalletListFreeFn, PersistAccountFn, PersistWalletMetadataFn, StandardAccountTypeTagFFI, - WalletRestoreEntryFFI, + AccountSpecFFI, AccountTypeTagFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, + LoadWalletListFn, LoadWalletListFreeFn, PersistAccountFn, PersistWalletMetadataFn, + StandardAccountTypeTagFFI, WalletRestoreEntryFFI, }; use dpp::address_funds::PlatformAddress; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::v0::IdentityV0; -use dpp::identity::Identity; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use platform_wallet::{DpnsNameInfo, IdentityManagerStartState, IdentityStatus, ManagedIdentity}; use std::ffi::CStr; @@ -907,13 +909,15 @@ fn build_wallet_start_state( /// The DPP `Identity` is reconstructed from the persisted scalars via /// the `IdentityV0` shape — same approach /// [`apply_identity_entry`](platform_wallet::IdentityManager::apply_identity_entry) -/// uses on the changeset replay path. Public keys are NOT pulled in -/// here; they live in `PersistentPublicKey` rows on the Swift side -/// and arrive separately via the next `on_persist_identity_keys_fn` -/// round during sync. Consequence: identities load with empty -/// `public_keys` until the first sync repopulates them — fine for -/// surfacing them in `inMemorySummary().identitiesCount` and -/// `managed_identity()` lookups, signing requires keys to refresh. +/// uses on the changeset replay path. Public keys are now pulled in +/// from the `keys` array on each `IdentityRestoreEntryFFI` (assembled +/// from the per-identity `PersistentPublicKey` rows on the Swift +/// side), so the restored `Identity.public_keys` map is populated at +/// load time. An identity with no persisted keys (e.g. an in-flight +/// registration whose key-persist round hasn't completed) loads with +/// an empty map and gets refreshed on the next sync round — same +/// degraded-but-usable behaviour as before this change for that +/// narrow case. fn build_wallet_identity_bucket( entry: &WalletRestoreEntryFFI, ) -> Result, PersistenceError> { @@ -928,9 +932,10 @@ fn build_wallet_identity_bucket( for spec in identity_specs { let identifier = Identifier::from(spec.identity_id); + let public_keys = unsafe { build_identity_public_keys(spec) }; let identity = Identity::V0(IdentityV0 { id: identifier, - public_keys: BTreeMap::new(), + public_keys, balance: spec.balance, revision: spec.revision, }); @@ -959,6 +964,60 @@ fn build_wallet_identity_bucket( Ok(bucket) } +/// Translate the `keys` array hanging off an `IdentityRestoreEntryFFI` +/// into a `BTreeMap` ready to drop into +/// `IdentityV0.public_keys`. +/// +/// Rows whose `key_type`, `purpose` or `security_level` discriminant +/// doesn't decode are skipped silently (forward-compatibility with +/// future enum variants on the Rust side); rows with null `data` +/// pointers or zero `data_len` are likewise skipped — neither is +/// recoverable, and the only consequence of skipping is the +/// auth-key-gate fallback to "fetch from chain on next sync". +/// +/// # Safety +/// +/// `spec.keys` must be either null or point at `spec.keys_count` +/// valid `IdentityKeyRestoreFFI` rows for the duration of the load +/// callback. Each row's `data` pointer must be either null or point +/// at `data_len` bytes Swift owns for the same window. +unsafe fn build_identity_public_keys( + spec: &IdentityRestoreEntryFFI, +) -> BTreeMap { + let mut map: BTreeMap = BTreeMap::new(); + if spec.keys.is_null() || spec.keys_count == 0 { + return map; + } + let rows: &[IdentityKeyRestoreFFI] = slice::from_raw_parts(spec.keys, spec.keys_count); + for row in rows { + let Ok(key_type) = KeyType::try_from(row.key_type) else { + continue; + }; + let Ok(purpose) = Purpose::try_from(row.purpose) else { + continue; + }; + let Ok(security_level) = SecurityLevel::try_from(row.security_level) else { + continue; + }; + if row.data.is_null() || row.data_len == 0 { + continue; + } + let bytes: Vec = slice::from_raw_parts(row.data, row.data_len).to_vec(); + let pk = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: row.key_id, + purpose, + security_level, + contract_bounds: None, + key_type, + read_only: row.read_only, + data: BinaryData::new(bytes), + disabled_at: None, + }); + map.insert(row.key_id, pk); + } + map +} + /// Decode a flat `*const *const c_char` array into a `Vec`. /// /// Used for the per-identity DPNS / contested-DPNS label arrays. The diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs index 155be9494ee..361a0ac18b9 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs @@ -4,13 +4,29 @@ use crate::error::*; use crate::handle::*; use crate::platform_address_types::*; use dpp::address_funds::PlatformAddress; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; use super::runtime; /// Fund platform addresses from a Core L1 asset lock. /// /// `asset_lock_proof_bytes` is the bincode-serialized `AssetLockProof`. -/// `private_key_bytes` must point to exactly 32 bytes. +/// `private_key_bytes` must point to exactly 32 bytes — this is the +/// asset-lock private key derived from the Core funding flow, used to +/// authorize the asset-lock contribution itself. It is unrelated to +/// the platform-address signer. +/// +/// `signer_address_handle` is a `*mut SignerHandle` produced by +/// `dash_sdk_signer_create_with_ctx` (e.g. via `KeychainSigner.handle`) +/// and is consumed as `Signer` for any +/// previously-funded input address present in `addresses` (entries +/// with `has_balance = true`). Even when the call only carries the +/// freshly-locked output (the single `has_balance = false` entry), +/// the signer slot is still required so the SDK can resolve the +/// trait bound — pass any valid handle (typically the wallet's +/// `KeychainSigner.handle`). The caller retains ownership; this +/// function does NOT destroy it. +/// /// Exactly one entry in `addresses` must have `has_balance = false`. /// /// Free result with `platform_address_wallet_free_changeset`. @@ -26,6 +42,7 @@ pub unsafe extern "C" fn platform_address_wallet_fund_from_asset_lock( private_key_bytes: *const u8, fee_strategy: *const FeeStrategyStepFFI, fee_strategy_count: usize, + signer_address_handle: *mut SignerHandle, out_changeset: *mut PlatformAddressChangeSetFFI, out_error: *mut PlatformWalletFFIError, ) -> PlatformWalletFFIResult { @@ -36,6 +53,15 @@ pub unsafe extern "C" fn platform_address_wallet_fund_from_asset_lock( { return PlatformWalletFFIResult::ErrorNullPointer; } + if signer_address_handle.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "signer_address_handle is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } // Parse addresses let mut address_map = std::collections::BTreeMap::new(); @@ -106,6 +132,10 @@ pub unsafe extern "C" fn platform_address_wallet_fund_from_asset_lock( let fee = parse_fee_strategy(fee_strategy, fee_strategy_count); + // SAFETY: caller guarantees `signer_address_handle` is a valid, + // non-destroyed handle that outlives this call. + let address_signer: &VTableSigner = &*(signer_address_handle as *const VTableSigner); + PLATFORM_ADDRESS_WALLET_STORAGE .with_item(handle, |wallet| { match runtime().block_on(wallet.fund_from_asset_lock( @@ -114,6 +144,7 @@ pub unsafe extern "C" fn platform_address_wallet_fund_from_asset_lock( asset_lock_proof, private_key, fee, + address_signer, )) { Ok(changeset) => { *out_changeset = PlatformAddressChangeSetFFI::from(&changeset); diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/transfer.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/transfer.rs index 46f061f21fc..8a7c867a696 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/transfer.rs @@ -3,11 +3,18 @@ use crate::error::*; use crate::handle::*; use crate::platform_address_types::*; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; use super::{parse_input_selection, runtime}; /// Transfer credits between platform addresses. /// +/// `signer_address_handle` is a `*mut SignerHandle` produced by +/// `dash_sdk_signer_create_with_ctx` (e.g. via `KeychainSigner.handle`) +/// and is consumed as `Signer` for each input +/// address. The caller retains ownership of the handle; this function +/// does NOT destroy it. +/// /// Free result with `platform_address_wallet_free_changeset`. #[no_mangle] #[allow(clippy::too_many_arguments)] @@ -23,12 +30,22 @@ pub unsafe extern "C" fn platform_address_wallet_transfer( outputs_count: usize, fee_strategy: *const FeeStrategyStepFFI, fee_strategy_count: usize, + signer_address_handle: *mut SignerHandle, out_changeset: *mut PlatformAddressChangeSetFFI, out_error: *mut PlatformWalletFFIError, ) -> PlatformWalletFFIResult { if out_changeset.is_null() { return PlatformWalletFFIResult::ErrorNullPointer; } + if signer_address_handle.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "signer_address_handle is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } let output_map = match parse_outputs(outputs, outputs_count) { Ok(m) => m, @@ -55,6 +72,10 @@ pub unsafe extern "C" fn platform_address_wallet_transfer( let fee = parse_fee_strategy(fee_strategy, fee_strategy_count); + // SAFETY: caller guarantees `signer_address_handle` is a valid, + // non-destroyed handle that outlives this call. + let address_signer: &VTableSigner = &*(signer_address_handle as *const VTableSigner); + PLATFORM_ADDRESS_WALLET_STORAGE .with_item(handle, |wallet| { match runtime().block_on(wallet.transfer( @@ -63,6 +84,7 @@ pub unsafe extern "C" fn platform_address_wallet_transfer( output_map, fee, None, // platform_version = latest + address_signer, )) { Ok(changeset) => { *out_changeset = PlatformAddressChangeSetFFI::from(&changeset); diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs index 509fafa16ae..8c332a8d5f9 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs @@ -4,11 +4,18 @@ use crate::error::*; use crate::handle::*; use crate::platform_address_types::*; use dpp::identity::core_script::CoreScript; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; use super::{parse_input_selection, runtime}; /// Withdraw platform credits to a Core L1 address. /// +/// `signer_address_handle` is a `*mut SignerHandle` produced by +/// `dash_sdk_signer_create_with_ctx` (e.g. via `KeychainSigner.handle`) +/// and is consumed as `Signer` for each input +/// address. The caller retains ownership of the handle; this function +/// does NOT destroy it. +/// /// Free result with `platform_address_wallet_free_changeset`. #[no_mangle] #[allow(clippy::too_many_arguments)] @@ -25,12 +32,22 @@ pub unsafe extern "C" fn platform_address_wallet_withdraw( core_fee_per_byte: u32, fee_strategy: *const FeeStrategyStepFFI, fee_strategy_count: usize, + signer_address_handle: *mut SignerHandle, out_changeset: *mut PlatformAddressChangeSetFFI, out_error: *mut PlatformWalletFFIError, ) -> PlatformWalletFFIResult { if out_changeset.is_null() || output_script.is_null() { return PlatformWalletFFIResult::ErrorNullPointer; } + if signer_address_handle.is_null() { + if !out_error.is_null() { + *out_error = PlatformWalletFFIError::new( + PlatformWalletFFIResult::ErrorNullPointer, + "signer_address_handle is null", + ); + } + return PlatformWalletFFIResult::ErrorNullPointer; + } let script_bytes = std::slice::from_raw_parts(output_script, output_script_len); let core_script = CoreScript::from_bytes(script_bytes.to_vec()); @@ -49,6 +66,10 @@ pub unsafe extern "C" fn platform_address_wallet_withdraw( let fee = parse_fee_strategy(fee_strategy, fee_strategy_count); + // SAFETY: caller guarantees `signer_address_handle` is a valid, + // non-destroyed handle that outlives this call. + let address_signer: &VTableSigner = &*(signer_address_handle as *const VTableSigner); + PLATFORM_ADDRESS_WALLET_STORAGE .with_item(handle, |wallet| { match runtime().block_on(wallet.withdraw( @@ -58,6 +79,7 @@ pub unsafe extern "C" fn platform_address_wallet_withdraw( core_fee_per_byte, fee, None, // platform_version = latest + address_signer, )) { Ok(changeset) => { *out_changeset = PlatformAddressChangeSetFFI::from(&changeset); diff --git a/packages/rs-platform-wallet-ffi/src/utils.rs b/packages/rs-platform-wallet-ffi/src/utils.rs index fe3e83af379..ba8f8261277 100644 --- a/packages/rs-platform-wallet-ffi/src/utils.rs +++ b/packages/rs-platform-wallet-ffi/src/utils.rs @@ -249,10 +249,90 @@ pub unsafe extern "C" fn platform_wallet_identifier_from_hex( } } +/// Compute hash160 (RIPEMD160(SHA256(data))) of the input bytes. +/// +/// Exposed so the Swift side can stamp a 20-byte public-key hash onto +/// the keychain `IdentityPrivateKeyMetadata` row at write time, without +/// pulling in a third-party RIPEMD-160 implementation in Swift +/// (CommonCrypto + CryptoKit don't expose it; the only RIPEMD-160 in +/// the iOS toolchain we already link is via `dashcore::hashes`, so we +/// reuse it here). +/// +/// # Parameters +/// - `data`: pointer to the input bytes (typically a 33-byte +/// compressed secp256k1 pubkey, but any length works). +/// - `data_len`: byte count for `data`. +/// - `out_hash`: pointer to a 20-byte buffer the caller has already +/// allocated. The function writes the resulting hash there on +/// success. +/// +/// Returns 0 on success, -1 on null pointer or zero length input. +/// +/// # Safety +/// - `data` must be a valid `[u8; data_len]` buffer for the duration of +/// the call. +/// - `out_hash` must be a valid `[u8; 20]` writable buffer. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_hash160( + data: *const u8, + data_len: usize, + out_hash: *mut u8, +) -> i32 { + if data.is_null() || data_len == 0 || out_hash.is_null() { + return -1; + } + use dashcore::hashes::Hash; + let bytes = std::slice::from_raw_parts(data, data_len); + let hash = dashcore::hashes::hash160::Hash::hash(bytes); + let h: [u8; 20] = hash.to_byte_array(); + std::ptr::copy_nonoverlapping(h.as_ptr(), out_hash, 20); + 0 +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_hash160_matches_known_vector() { + // Test vector: hash160 of the all-zero 33-byte compressed + // pubkey. RIPEMD160(SHA256(0x00 * 33)) = known constant. The + // exact value is captured by re-deriving via dashcore — this + // guards against accidental changes to either the FFI shape + // or the underlying hasher. + use dashcore::hashes::Hash; + let input = [0u8; 33]; + let mut out = [0u8; 20]; + let rc = unsafe { platform_wallet_hash160(input.as_ptr(), input.len(), out.as_mut_ptr()) }; + assert_eq!(rc, 0); + let expected = dashcore::hashes::hash160::Hash::hash(&input); + let expected_bytes: [u8; 20] = expected.to_byte_array(); + assert_eq!(out, expected_bytes); + } + + #[test] + fn test_hash160_rejects_null_input() { + let mut out = [0u8; 20]; + let rc = unsafe { platform_wallet_hash160(std::ptr::null(), 33, out.as_mut_ptr()) }; + assert_eq!(rc, -1); + } + + #[test] + fn test_hash160_rejects_zero_len() { + let input = [0u8; 33]; + let mut out = [0u8; 20]; + let rc = unsafe { platform_wallet_hash160(input.as_ptr(), 0, out.as_mut_ptr()) }; + assert_eq!(rc, -1); + } + + #[test] + fn test_hash160_rejects_null_out() { + let input = [0u8; 33]; + let rc = + unsafe { platform_wallet_hash160(input.as_ptr(), input.len(), std::ptr::null_mut()) }; + assert_eq!(rc, -1); + } + #[test] fn test_serialize_deserialize_json_bytes() { unsafe { diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index 127462683e4..10a370a8cac 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -99,6 +99,49 @@ pub struct AccountSpecFFI { pub account_xpub_bytes_len: usize, } +/// Per-identity public-key row carried on +/// [`IdentityRestoreEntryFFI::keys`]. +/// +/// Mirrors the persisted `PersistentPublicKey` columns Swift writes +/// during the `on_persist_identity_keys_fn` round. Carrying them on the +/// load path means each restored `Identity` enters the in-memory +/// `IdentityManager` with a populated `public_keys` `BTreeMap` instead +/// of an empty one — the original gap that left +/// `Identity::public_keys()` empty after cold-start until the next sync +/// round repopulated it. +/// +/// Field discriminants match the DPP `repr(u8)` enum layouts (same +/// convention used by [`crate::identity_registration_with_signer::IdentityPubkeyFFI`]): +/// - `key_type`: [`dpp::identity::KeyType`] discriminant +/// (0 = ECDSA_SECP256K1, …). +/// - `purpose`: [`dpp::identity::Purpose`] discriminant +/// (0 = AUTHENTICATION, …). +/// - `security_level`: [`dpp::identity::SecurityLevel`] discriminant +/// (0 = MASTER, 1 = CRITICAL, 2 = HIGH, 3 = MEDIUM). +/// +/// `data` is the public-key bytes (compressed secp256k1 → 33 bytes; +/// BLS → 48; etc.). The pointer is Swift-owned and valid only for the +/// duration of the load callback. +/// +/// Disabled-at, contract-bounds and other non-essential fields are +/// intentionally omitted — they're either always `None` for newly +/// derived identity-auth keys or get re-populated by the next +/// identity sync round if they exist on chain. The scope of this +/// restore is narrowly "make `Identity.public_keys` non-empty so +/// auth-key gates pass". +#[repr(C)] +pub struct IdentityKeyRestoreFFI { + pub key_id: u32, + pub key_type: u8, + pub purpose: u8, + pub security_level: u8, + pub read_only: bool, + /// Public-key bytes (33 for ECDSA_SECP256K1; 48 for BLS; etc.). + /// Valid for callback duration only; Swift owns the allocation. + pub data: *const u8, + pub data_len: usize, +} + /// Per-identity entry attached to a [`WalletRestoreEntryFFI`]. /// /// Carries the scalar fields needed to rebuild a `ManagedIdentity` @@ -115,16 +158,18 @@ pub struct AccountSpecFFI { /// `IdentityManager::apply_identity_entry` does on the changeset /// replay path — so no full `Identity` blob crosses the FFI. /// -/// Public keys are NOT carried here; they live in the per-identity -/// `PersistentPublicKey` rows on the Swift side and arrive separately -/// via the existing `on_persist_identity_keys_fn` callback during the -/// next sync round. Identities load with empty `public_keys` — -/// sufficient to surface them in the explorer and -/// `IdentityManager::managed_identity()` lookups. +/// Public keys ride along on `keys` / `keys_count` as +/// `IdentityKeyRestoreFFI` rows assembled from the per-identity +/// `PersistentPublicKey` rows on the Swift side. Each row is converted +/// into an `IdentityPublicKey::V0` and inserted into the +/// reconstructed `Identity.public_keys` map keyed by `key_id`. When +/// `keys_count == 0` the identity loads with an empty `public_keys` +/// map (e.g. an in-flight registration whose key persist round +/// hasn't completed); a subsequent sync round refreshes it. /// -/// All pointer fields (`dpns_names`, `contested_dpns_names`) are -/// Swift-owned and valid only for the duration of the load callback. -/// The matching free callback releases them. +/// All pointer fields (`dpns_names`, `contested_dpns_names`, `keys`) +/// are Swift-owned and valid only for the duration of the load +/// callback. The matching free callback releases them. #[repr(C)] pub struct IdentityRestoreEntryFFI { /// 32-byte identifier. @@ -159,6 +204,14 @@ pub struct IdentityRestoreEntryFFI { /// Same array shape as `dpns_names`. `null` when none. pub contested_dpns_names: *const *const c_char, pub contested_dpns_names_count: usize, + /// Identity public-key rows assembled from the per-identity + /// `PersistentPublicKey` SwiftData rows. Each row is folded into + /// the reconstructed `Identity.public_keys` map keyed by + /// `key_id`. `null` / `0` when the identity has no persisted + /// keys (e.g. an in-flight registration whose key-persist round + /// hasn't completed). + pub keys: *const IdentityKeyRestoreFFI, + pub keys_count: usize, } /// Per-wallet entry returned by `on_load_wallet_list_fn`. @@ -196,6 +249,8 @@ pub struct WalletRestoreEntryFFI { // use must happen within the callback window. unsafe impl Send for AccountSpecFFI {} unsafe impl Sync for AccountSpecFFI {} +unsafe impl Send for IdentityKeyRestoreFFI {} +unsafe impl Sync for IdentityKeyRestoreFFI {} unsafe impl Send for IdentityRestoreEntryFFI {} unsafe impl Sync for IdentityRestoreEntryFFI {} unsafe impl Send for WalletRestoreEntryFFI {} @@ -219,9 +274,11 @@ pub type LoadWalletListFn = unsafe extern "C" fn( /// Paired free callback for `LoadWalletListFn`. Releases any memory /// Swift allocated for the entries array, the per-wallet accounts /// arrays, the optional per-wallet platform-address balance arrays, -/// every xpub byte buffer, the per-wallet identity arrays, and every +/// every xpub byte buffer, the per-wallet identity arrays, every /// nested c-string + c-string pointer array carried by the identity -/// entries. Called exactly once after a successful `LoadWalletListFn` +/// entries, and every per-identity `IdentityKeyRestoreFFI` array +/// together with the public-key byte buffers each row points at. +/// Called exactly once after a successful `LoadWalletListFn` /// invocation. pub type LoadWalletListFreeFn = unsafe extern "C" fn(context: *mut c_void, entries: *const WalletRestoreEntryFFI, count: usize); diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index d5a58448c9b..5e3b5be6bd5 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -26,7 +26,6 @@ async-trait = "0.1" arc-swap = "1" # Collections -indexmap = "2.0" bimap = "0.6" # Async runtime diff --git a/packages/rs-platform-wallet/examples/basic_usage.rs b/packages/rs-platform-wallet/examples/basic_usage.rs index e422bf1cdf6..20e6db8cd81 100644 --- a/packages/rs-platform-wallet/examples/basic_usage.rs +++ b/packages/rs-platform-wallet/examples/basic_usage.rs @@ -94,7 +94,7 @@ async fn main() -> Result<(), Box> { .unwrap_or_default(); let tx_count = state.core_wallet.transaction_history().len(); let birth = state.core_wallet.birth_height(); - let id_count = state.identity_manager.identities().len(); + let id_count = state.identity_manager.identity_count(); println!( "UTXOs: {}, transactions: {}, birth_height: {}", utxos.len(), diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 32c7790367b..bb1faeb0f04 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -16,18 +16,50 @@ use crate::wallet::PlatformWallet; use super::PlatformWalletManager; +/// Parse a BIP-39 mnemonic against every supported wordlist in turn, +/// returning the first language that yields a valid mnemonic. +/// +/// `key_wallet::Mnemonic` only exposes language-tagged constructors, +/// so callers that take a user-supplied mnemonic must walk the +/// language list themselves to avoid rejecting non-English phrases as +/// "invalid English". BIP-39 wordlists are mutually exclusive per +/// phrase, so the first match is unambiguous. +fn parse_mnemonic_any_language(phrase: &str) -> Result { + const LANGUAGES: [Language; 10] = [ + Language::English, + Language::Spanish, + Language::French, + Language::Italian, + Language::Japanese, + Language::Korean, + Language::ChineseSimplified, + Language::ChineseTraditional, + Language::Czech, + Language::Portuguese, + ]; + for lang in LANGUAGES { + if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { + return Ok(m); + } + } + Err("phrase does not match any supported BIP-39 wordlist") +} + impl PlatformWalletManager

{ /// Create a PlatformWallet from a BIP39 mnemonic phrase. /// - /// The mnemonic is parsed as English. For other languages or passphrases, - /// derive the seed externally and use [`create_wallet_from_seed_bytes`]. + /// The mnemonic's language is auto-detected by trying each + /// supported BIP-39 wordlist in turn (see + /// [`parse_mnemonic_any_language`]). For passphrase-only flows or + /// out-of-band seed material, derive the seed externally and use + /// [`Self::create_wallet_from_seed_bytes`]. pub async fn create_wallet_from_mnemonic( &self, mnemonic_phrase: &str, network: Network, accounts: WalletAccountCreationOptions, ) -> Result, PlatformWalletError> { - let mnemonic = Mnemonic::from_phrase(mnemonic_phrase, Language::English) + let mnemonic = parse_mnemonic_any_language(mnemonic_phrase) .map_err(|e| PlatformWalletError::WalletCreation(format!("Invalid mnemonic: {}", e)))?; let wallet = Wallet::from_mnemonic(mnemonic, network, accounts).map_err(|e| { PlatformWalletError::WalletCreation(format!( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 873e68e91b7..d4422ed5efa 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -1,13 +1,20 @@ //! DashPay contact request lifecycle: send, sync, accept, reject. +use async_trait::async_trait; +use dpp::address_funds::AddressWitness; use dpp::document::DocumentV0Getters; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::Purpose; +use dpp::identity::signer::Signer; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; +use dpp::identity::KeyType; +use dpp::identity::SecurityLevel; +use dpp::platform_value::BinaryData; use dpp::platform_value::Value; use dpp::prelude::Identifier; +use dpp::ProtocolError; use key_wallet::account::AccountType; use dash_sdk::platform::dashpay::EcdhProvider; @@ -20,6 +27,38 @@ use crate::wallet::identity::types::dashpay::established_contact::EstablishedCon use crate::wallet::signer::IdentitySigner; use dash_sdk::platform::dashpay::SendContactRequestInput; +// Borrowed-signer adapter — see `dpns.rs` for the pattern. +struct SignerRef<'a, S: ?Sized>(&'a S); + +impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignerRef") + } +} + +#[async_trait] +impl<'a, K, S> Signer for SignerRef<'a, S> +where + K: Send + Sync, + S: Signer + ?Sized + Send + Sync, +{ + async fn sign(&self, key: &K, data: &[u8]) -> Result { + self.0.sign(key, data).await + } + + async fn sign_create_witness( + &self, + key: &K, + data: &[u8], + ) -> Result { + self.0.sign_create_witness(key, data).await + } + + fn can_sign_with(&self, key: &K) -> bool { + self.0.can_sign_with(key) + } +} + // --------------------------------------------------------------------------- // Send contact request // --------------------------------------------------------------------------- @@ -34,6 +73,17 @@ impl IdentityWallet { /// - **account_index**: defaults to `0` /// - **ECDH**: performed SDK-side using the sender's derived encryption private key /// + /// # Superseded — prefer [`Self::send_contact_request_with_external_signer`] + /// + /// This variant constructs an internal `IdentitySigner` and dies + /// on watch-only wallets. NOTE: contact-request submission also + /// needs ECDH derivation of the sender's encryption private key, + /// which the external-signer variant still derives Rust-side + /// (requires the seed). True watch-only support for contact + /// requests will require a follow-up that pushes ECDH derivation + /// across the FFI as well — see the external-signer variant's + /// docstring. + /// /// # Arguments /// /// * `sender_identity_id` - Identity that owns the contact request. @@ -153,13 +203,21 @@ impl IdentityWallet { identity_index, ); let identity_public_key = sender_identity - .public_keys() - .values() - .find(|k| k.purpose() == Purpose::AUTHENTICATION) + // Contact-request send writes a document state transition, + // which DPP requires to be signed by a HIGH-or-stricter + // authentication key. MASTER is rejected on document writes. + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) .cloned() .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( - "Sender identity has no authentication key for signing".to_string(), + "Sender identity has no HIGH or CRITICAL authentication key \ + (required for document state transitions)" + .to_string(), ) })?; @@ -254,6 +312,240 @@ impl IdentityWallet { Ok(contact_request) } + + /// Send a contact request to another identity using an + /// externally-supplied signer for the document state-transition. + /// + /// Mirrors [`Self::send_contact_request`] but routes the + /// document signing through `signer` instead of an internal + /// `IdentitySigner`. Required for external-signable wallets and + /// the architecturally correct path per `swift-sdk/CLAUDE.md`. + /// + /// CAVEAT — ECDH derivation: this method still derives the + /// sender's ECDH private key from the wallet seed via + /// `derive_encryption_private_key`. Watch-only wallets (no seed + /// Rust-side) WILL fail at this step. A follow-up FFI is needed + /// to push ECDH derivation across the FFI (it's a one-shot raw + /// scalar derivation, not a `Signer::sign` call, so it + /// doesn't fit the existing signer trampoline). For wallets + /// where the seed is in-process (the common case during this + /// migration sweep) this variant works end-to-end and replaces + /// the panic-prone `IdentitySigner` path on the document side. + #[allow(clippy::type_complexity)] + pub async fn send_contact_request_with_external_signer( + &self, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, + account_label: Option, + auto_accept_proof: Option>, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + // 1. Retrieve the sender identity and its HD index from the + // local manager. + let (sender_identity, identity_index) = { + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity(sender_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*sender_identity_id))?; + let index = managed + .identity_index + .ok_or(PlatformWalletError::IdentityIndexNotSet( + *sender_identity_id, + ))?; + (managed.identity.clone(), index) + }; + + // 2. Fetch the recipient identity from Platform. + let recipient_identity = { + use dash_sdk::platform::Fetch; + Identity::fetch(&self.sdk, *recipient_identity_id) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch recipient identity: {}", + e + )) + })? + .ok_or_else(|| PlatformWalletError::IdentityNotFound(*recipient_identity_id))? + }; + + // 3. Resolve key indices. + let sender_encryption_key = sender_identity + .public_keys() + .iter() + .find(|(_, k)| k.purpose() == Purpose::ENCRYPTION) + .map(|(_, k)| k.clone()) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Sender identity has no encryption key".to_string(), + ) + })?; + let sender_key_index = sender_encryption_key.id(); + + let recipient_key_index = recipient_identity + .public_keys() + .iter() + .find(|(_, k)| k.purpose() == Purpose::DECRYPTION) + .map(|(id, _)| *id) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Recipient identity has no decryption key".to_string(), + ) + })?; + + // 4. Derive the DashPay receiving xpub + ECDH private key from + // the wallet seed. NOTE: this step still requires the seed + // in-process (see CAVEAT in the docstring). + let account_index: u32 = 0; + let (xpub_bytes, ecdh_private_key) = { + let wm = self.wallet_manager.read().await; + let wallet = wm + .get_wallet(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + + let account_type = AccountType::DashpayReceivingFunds { + index: account_index, + user_identity_id: sender_identity_id.to_buffer(), + friend_identity_id: recipient_identity_id.to_buffer(), + }; + let account_path = account_type + .derivation_path(self.sdk.network) + .map_err(|err| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive DashPay receiving account path: {err}" + )) + })?; + let account_xpub = wallet + .derive_extended_public_key(&account_path) + .map_err(|err| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive DashPay receiving account xpub: {err}" + )) + })?; + let xpub = account_xpub.encode(); + + let ecdh_key = Self::derive_encryption_private_key( + wallet, + self.sdk.network, + identity_index, + &sender_encryption_key, + )?; + + (xpub, ecdh_key) + }; + + // 5. Build the signing key reference for document signing. + let identity_public_key = sender_identity + // Contact-request send writes a document state transition, + // which DPP requires to be signed by a HIGH-or-stricter + // authentication key. MASTER is rejected on document writes. + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Sender identity has no HIGH or CRITICAL authentication key \ + (required for document state transitions)" + .to_string(), + ) + })?; + + // 6. Build SDK input. Wrap the borrowed signer in `SignerRef` + // so it satisfies the owned-by-bound `Signer` + // requirement on `SendContactRequestInput`. + let contact_request_input = dash_sdk::platform::dashpay::ContactRequestInput { + sender_identity: sender_identity.clone(), + recipient: dash_sdk::platform::dashpay::RecipientIdentity::Identity(recipient_identity), + sender_key_index, + recipient_key_index, + account_reference: account_index, + account_label, + auto_accept_proof, + }; + + let send_input = SendContactRequestInput { + contact_request: contact_request_input, + identity_public_key, + signer: SignerRef(signer), + }; + + let expected_key_id = sender_key_index; + let ecdh_provider: EcdhProvider< + _, + _, + fn( + &dashcore::secp256k1::PublicKey, + ) -> std::future::Ready>, + _, + > = EcdhProvider::SdkSide { + get_private_key: move |key: &IdentityPublicKey, _index: u32| { + let pk = ecdh_private_key; + let actual_key_id = key.id(); + async move { + if actual_key_id != expected_key_id { + return Err(dash_sdk::Error::Generic(format!( + "ECDH key mismatch: expected key {}, got {}", + expected_key_id, actual_key_id + ))); + } + Ok(pk) + } + }, + }; + + let xpub_bytes_clone = xpub_bytes.clone(); + let result = self + .sdk + .send_contact_request(send_input, ecdh_provider, |_account_ref: u32| async move { + Ok::, dash_sdk::Error>(xpub_bytes_clone) + }) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to send contact request: {e}" + )) + })?; + + // 7. Mirror the local-state bookkeeping in `send_contact_request`. + let contact_request = ContactRequest::new( + *sender_identity_id, + result.recipient_id, + sender_key_index, + recipient_key_index, + result.account_reference, + vec![0u8; 96], + result.document.created_at_core_block_height().unwrap_or(0), + result.document.created_at().unwrap_or(0), + ); + + { + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity_mut(sender_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*sender_identity_id))?; + managed.add_sent_contact_request(contact_request.clone(), &self.persister); + } + + self.register_contact_account(sender_identity_id, recipient_identity_id, account_index) + .await?; + + Ok(contact_request) + } } // --------------------------------------------------------------------------- @@ -417,6 +709,12 @@ impl IdentityWallet { /// - ECDH, signing key, identity index, and account index are resolved the /// same way as [`send_contact_request`]. /// + /// # Superseded — prefer [`Self::accept_contact_request_with_external_signer`] + /// + /// Internally calls the legacy `send_contact_request`, so it + /// inherits the same `IdentitySigner` panic on watch-only + /// wallets. + /// /// # Arguments /// /// * `request` - The incoming [`ContactRequest`] to accept. @@ -495,6 +793,94 @@ impl IdentityWallet { .cloned() .ok_or(PlatformWalletError::ContactRequestNotFound(sender_id)) } + + /// Accept an incoming contact request using an externally-supplied + /// signer. + /// + /// Mirrors [`Self::accept_contact_request`] but routes through + /// [`Self::send_contact_request_with_external_signer`] so signing + /// crosses the FFI via the supplied `&S: Signer`. + /// Same ECDH caveat applies — see that method's docstring. + pub async fn accept_contact_request_with_external_signer( + &self, + request: &ContactRequest, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let our_identity_id = request.recipient_id; + let sender_id = request.sender_id; + + // 1. Verify the incoming request is known. + { + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity(&our_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(our_identity_id))?; + if !managed.incoming_contact_requests.contains_key(&sender_id) { + return Err(PlatformWalletError::ContactRequestNotFound(sender_id)); + } + } + + // 2. Capture the encrypted xpub + key indices BEFORE sending + // the reciprocal request (same ordering as the legacy + // `accept_contact_request`). + let contact_encrypted_xpub = request.encrypted_public_key.clone(); + let our_decryption_key_index = request.recipient_key_index; + let contact_encryption_key_index = request.sender_key_index; + + // 3. Send reciprocal request via the external-signer path. + self.send_contact_request_with_external_signer( + &our_identity_id, + &sender_id, + None, + None, + signer, + ) + .await?; + + // 4. Best-effort external-account registration. Failures are + // logged but do not abort. + if let Err(e) = self + .register_external_contact_account( + &our_identity_id, + &sender_id, + &contact_encrypted_xpub, + our_decryption_key_index, + contact_encryption_key_index, + ) + .await + { + tracing::warn!( + our_identity = %our_identity_id, + contact = %sender_id, + error = %e, + "Failed to register external contact account after accept (external signer) — \ + re-run register_external_contact_account to retry" + ); + } + + // 5. Retrieve the auto-established contact. + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity(&our_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(our_identity_id))?; + + managed + .established_contacts + .get(&sender_id) + .cloned() + .ok_or(PlatformWalletError::ContactRequestNotFound(sender_id)) + } } // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs index 2a199a7160b..29a05920afc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs @@ -10,12 +10,63 @@ use dpp::identity::SecurityLevel; use dpp::prelude::Identifier; use dpp::identity::signer::Signer; +use dpp::ProtocolError; + +use async_trait::async_trait; +use dpp::address_funds::AddressWitness; +use dpp::platform_value::BinaryData; use crate::error::PlatformWalletError; use crate::wallet::identity::types::key_storage::DpnsNameInfo; use super::*; +// --------------------------------------------------------------------------- +// Signer reference adapter +// --------------------------------------------------------------------------- + +/// Thin wrapper that lets us pass a borrowed `&S` (where `S: Signer`) +/// into APIs whose generic bound requires an owned `S2: Signer`. +/// +/// `dpp::identity::signer::Signer` is not implemented for `&T`, so a +/// caller who only has `&KeychainSigner` can't directly satisfy +/// `RegisterDpnsNameInput>`. This wrapper +/// owns a `&'a S`, copies the trait's `Send + Sync + Debug` bounds, and +/// delegates each method back to the inner reference. Used internally +/// by [`IdentityWallet::register_name_with_external_signer`] so the +/// public FFI surface can pass the iOS `KeychainSigner` by reference +/// without forcing a clone or `Arc` round-trip per call. +struct SignerRef<'a, S: ?Sized>(&'a S); + +impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignerRef") + } +} + +#[async_trait] +impl<'a, K, S> Signer for SignerRef<'a, S> +where + K: Send + Sync, + S: Signer + ?Sized + Send + Sync, +{ + async fn sign(&self, key: &K, data: &[u8]) -> Result { + self.0.sign(key, data).await + } + + async fn sign_create_witness( + &self, + key: &K, + data: &[u8], + ) -> Result { + self.0.sign_create_witness(key, data).await + } + + fn can_sign_with(&self, key: &K) -> bool { + self.0.can_sign_with(key) + } +} + // --------------------------------------------------------------------------- // Contest vote-state public types // --------------------------------------------------------------------------- @@ -100,17 +151,25 @@ impl IdentityWallet { let index = manager .identity_index(identity_id) .ok_or(PlatformWalletError::IdentityIndexNotSet(*identity_id))?; - // Use the first authentication key (key_id 0). + // DPNS name registration writes a document state transition, + // which DPP requires to be signed by a HIGH-or-stricter + // authentication key. MASTER is intentionally excluded — + // it's reserved for identity-self-modification operations + // (identity update, key rotation, withdrawal) and is + // rejected by the protocol on document-side state + // transitions. let key = identity .get_first_public_key_matching( Purpose::AUTHENTICATION, - [SecurityLevel::MASTER, SecurityLevel::HIGH].into(), + [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), [KeyType::ECDSA_SECP256K1].into(), false, ) .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( - "No authentication key found on identity".to_string(), + "No HIGH or CRITICAL authentication key found on identity \ + (required for document state transitions)" + .to_string(), ) })? .clone(); @@ -204,6 +263,130 @@ impl IdentityWallet { Ok(result.full_domain_name) } + /// Register a DPNS name using an externally-supplied signer. + /// + /// Same shape as [`Self::register_name`] but signing is routed + /// through the supplied `&S: Signer` instead of + /// the wallet's own `IdentitySigner`. Required for external-signable + /// wallets (no seed Rust-side) and the architecturally correct path + /// per `swift-sdk/CLAUDE.md`. + /// + /// The identity is still looked up from the in-process + /// `IdentityManager` (so we can locate the HIGH/CRITICAL + /// authentication key the document state transition requires), but + /// `signer.sign(...)` is invoked through the caller-supplied + /// trait object rather than the wallet-derived `IdentitySigner`. + /// This avoids re-acquiring the `wallet_manager` lock from inside + /// the signing path (which would deadlock the Tokio worker if the + /// signer used `blocking_read` to derive a private key) and lets + /// watch-only wallets — where the seed lives in iOS Keychain + /// rather than the in-process `WalletManager` — register names. + /// + /// On success the just-registered label is appended to + /// `ManagedIdentity.dpns_names` and an identity changeset is queued + /// (identical book-keeping to [`Self::register_name`]). + pub async fn register_name_with_external_signer( + &self, + identity_id: &Identifier, + name: &str, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + use dash_sdk::platform::dpns_usernames::RegisterDpnsNameInput; + + let (identity, auth_key) = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let manager = &info.identity_manager; + let identity = manager + .identity(identity_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + // DPNS name registration writes a document state transition, + // which DPP requires to be signed by a HIGH-or-stricter + // authentication key. MASTER is intentionally excluded — + // it's reserved for identity-self-modification operations + // (identity update, key rotation, withdrawal) and is + // rejected by the protocol on document-side state + // transitions. + let key = identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "No HIGH or CRITICAL authentication key found on identity \ + (required for document state transitions)" + .to_string(), + ) + })? + .clone(); + (identity, key) + }; + + let input = RegisterDpnsNameInput { + label: name.to_string(), + identity, + identity_public_key: auth_key, + // Wrap the borrowed signer in `SignerRef` so the + // `RegisterDpnsNameInput>` + // bound is satisfied without forcing the caller to hand + // over ownership / wrap in an Arc per call. + signer: SignerRef(signer), + preorder_callback: None, + }; + + let result = self.sdk.register_dpns_name(input).await.map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to register DPNS name '{}': {}", + name, e + )) + })?; + + // Same book-keeping as `register_name`: append the just- + // registered label to `ManagedIdentity.dpns_names` and emit + // an `IdentityChangeSet` so the Swift persister callback + // refreshes `PersistentIdentity.dpnsName` automatically. + // Skip on duplicate to avoid emitting a redundant entry on + // re-runs. + let acquired_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .ok(); + let label_to_store = name.to_string(); + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { + if !managed + .dpns_names + .iter() + .any(|existing| existing.label == label_to_store) + { + managed.add_dpns_name( + DpnsNameInfo { + label: label_to_store, + acquired_at, + }, + &self.persister, + ); + } + } + } + } + + Ok(result.full_domain_name) + } + /// Resolve a DPNS name to an identity identifier. /// /// Accepts both "alice" and "alice.dash" formats. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index bd5370af123..30fc5ff0883 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -2,18 +2,57 @@ use std::sync::Arc; +use async_trait::async_trait; +use dpp::address_funds::AddressWitness; use dpp::document::DocumentV0Getters; use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::Purpose; +use dpp::identity::signer::Signer; +use dpp::identity::IdentityPublicKey; +use dpp::identity::KeyType; +use dpp::identity::SecurityLevel; +use dpp::platform_value::BinaryData; use dpp::platform_value::Value; use dpp::prelude::Identifier; +use dpp::ProtocolError; use super::*; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; use crate::wallet::signer::IdentitySigner; +// Borrowed-signer adapter — see `dpns.rs` for the pattern. +struct SignerRef<'a, S: ?Sized>(&'a S); + +impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignerRef") + } +} + +#[async_trait] +impl<'a, K, S> Signer for SignerRef<'a, S> +where + K: Send + Sync, + S: Signer + ?Sized + Send + Sync, +{ + async fn sign(&self, key: &K, data: &[u8]) -> Result { + self.0.sign(key, data).await + } + + async fn sign_create_witness( + &self, + key: &K, + data: &[u8], + ) -> Result { + self.0.sign_create_witness(key, data).await + } + + fn can_sign_with(&self, key: &K) -> bool { + self.0.can_sign_with(key) + } +} + // --------------------------------------------------------------------------- // Sync profiles // --------------------------------------------------------------------------- @@ -185,6 +224,14 @@ impl IdentityWallet { impl IdentityWallet { /// Create a new DashPay profile document on Platform for `identity_id`. /// + /// # Superseded — prefer [`Self::create_profile_with_external_signer`] + /// + /// Same rationale as the `transfer_credits` deprecation: the + /// internal `IdentitySigner` path dies on watch-only wallets and + /// can deadlock the Tokio worker. New callers should pass an + /// external `&S: Signer` (typically the iOS + /// `KeychainSigner`). + /// /// Steps: /// 1. Load the DashPay contract. /// 2. Compute `avatarHash` (SHA-256) and `avatarFingerprint` (dHash) @@ -266,13 +313,24 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityIndexNotSet(*identity_id))?; let key = managed .identity - .public_keys() - .values() - .find(|k| k.purpose() == Purpose::AUTHENTICATION) + // DashPay profile create/update writes a document state + // transition, which DPP requires to be signed by a + // HIGH-or-stricter authentication key. MASTER is + // intentionally excluded — it's reserved for identity + // self-modification (update / key rotation / + // withdrawal) and rejected on document writes. + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) .cloned() .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( - "Identity has no authentication key for signing".to_string(), + "No HIGH or CRITICAL authentication key found on identity \ + (required for document state transitions)" + .to_string(), ) })?; (managed.identity.clone(), idx, key) @@ -351,6 +409,10 @@ impl IdentityWallet { /// Update an existing DashPay profile on Platform for `identity_id`. /// + /// # Superseded — prefer [`Self::update_profile_with_external_signer`] + /// + /// Same rationale as `create_profile` deprecation. + /// /// Fetches the current profile document to obtain its ID and revision, /// applies the fields from `input`, then broadcasts a document replace /// transition. The local cache is updated on success. @@ -467,13 +529,24 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; let key = managed .identity - .public_keys() - .values() - .find(|k| k.purpose() == Purpose::AUTHENTICATION) + // DashPay profile create/update writes a document state + // transition, which DPP requires to be signed by a + // HIGH-or-stricter authentication key. MASTER is + // intentionally excluded — it's reserved for identity + // self-modification (update / key rotation / + // withdrawal) and rejected on document writes. + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) .cloned() .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( - "Identity has no authentication key for signing".to_string(), + "No HIGH or CRITICAL authentication key found on identity \ + (required for document state transitions)" + .to_string(), ) })?; // Profile update path is wallet-owned-only — same guard as @@ -557,3 +630,373 @@ impl IdentityWallet { Ok(profile) } } + +// --------------------------------------------------------------------------- +// Profile create / update — external-signer variants +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Create a DashPay profile document using an externally-supplied + /// signer. + /// + /// Mirrors [`Self::create_profile`] but signing is routed through + /// the supplied `&S: Signer`. The signing key + /// is still resolved from the identity's `public_keys` map (first + /// AUTHENTICATION key, matching the legacy variant) — the signer + /// is responsible for producing a signature for whatever key is + /// picked. + /// + /// All other behavior — avatar hashing, document construction, + /// local cache update via the persister — is identical to the + /// legacy variant. + pub async fn create_profile_with_external_signer( + &self, + identity_id: &Identifier, + input: crate::wallet::identity::ProfileUpdate, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + use dash_sdk::platform::transition::put_document::PutDocument; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::document::Document; + use dpp::document::DocumentV0; + + // 1. Load the DashPay data contract. + let dashpay_contract = Arc::new( + dpp::system_data_contracts::load_system_data_contract( + dpp::data_contracts::SystemDataContract::Dashpay, + dpp::version::PlatformVersion::latest(), + ) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to load DashPay contract: {e}" + )) + })?, + ); + + // 2. Compute avatar hashes when raw bytes are provided. + let (avatar_hash, avatar_fingerprint) = if let Some(ref bytes) = input.avatar_bytes { + let hash = crate::wallet::identity::calculate_avatar_hash(bytes); + let fingerprint = crate::wallet::identity::calculate_dhash_fingerprint(bytes) + .map_err(PlatformWalletError::InvalidIdentityData)?; + (Some(hash), Some(fingerprint)) + } else { + (None, None) + }; + + // 3. Build the document property map. + let mut properties = std::collections::BTreeMap::new(); + if let Some(ref name) = input.display_name { + properties.insert("displayName".to_string(), Value::Text(name.clone())); + } + if let Some(ref msg) = input.public_message { + properties.insert("publicMessage".to_string(), Value::Text(msg.clone())); + } + if let Some(ref url) = input.avatar_url { + properties.insert("avatarUrl".to_string(), Value::Text(url.clone())); + } + if let Some(hash) = avatar_hash { + properties.insert("avatarHash".to_string(), Value::Bytes32(hash)); + } + if let Some(fp) = avatar_fingerprint { + properties.insert("avatarFingerprint".to_string(), Value::Bytes(fp.to_vec())); + } + + // 4. Look up identity + signing key. We no longer need the + // identity_index — the signer is supplied externally. + let signing_key = { + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + managed + .identity + // DashPay profile create/update writes a document state + // transition, which DPP requires to be signed by a + // HIGH-or-stricter authentication key. MASTER is + // intentionally excluded — it's reserved for identity + // self-modification (update / key rotation / + // withdrawal) and rejected on document writes. + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "No HIGH or CRITICAL authentication key found on identity \ + (required for document state transitions)" + .to_string(), + ) + })? + }; + + let stub_document = Document::V0(DocumentV0 { + id: Identifier::from([0u8; 32]), + owner_id: *identity_id, + properties, + revision: None, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }); + + let profile_document_type = dashpay_contract + .document_type_for_name("profile") + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to get profile document type: {e}" + )) + })? + .to_owned_document_type(); + + let _result_doc = stub_document + .put_to_platform_and_wait_for_response( + &self.sdk, + profile_document_type, + None, + signing_key, + None, + &SignerRef(signer), + None, + ) + .await + .map_err(PlatformWalletError::Sdk)?; + + let profile = crate::wallet::identity::DashPayProfile { + display_name: input.display_name, + bio: input.public_message.clone(), + avatar_url: input.avatar_url, + avatar_hash, + avatar_fingerprint, + public_message: input.public_message, + }; + + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { + managed.set_dashpay_profile(Some(profile.clone()), &self.persister); + } + } + } + + Ok(profile) + } + + /// Update an existing DashPay profile document using an + /// externally-supplied signer. + /// + /// Mirrors [`Self::update_profile`] but signing is routed through + /// the supplied `&S: Signer`. + pub async fn update_profile_with_external_signer( + &self, + identity_id: &Identifier, + input: crate::wallet::identity::ProfileUpdate, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + use dash_sdk::platform::transition::put_document::PutDocument; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::document::Document; + use dpp::document::DocumentV0; + use dpp::document::INITIAL_REVISION; + + // 1. Load the DashPay contract. + let dashpay_contract = Arc::new( + dpp::system_data_contracts::load_system_data_contract( + dpp::data_contracts::SystemDataContract::Dashpay, + dpp::version::PlatformVersion::latest(), + ) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to load DashPay contract: {e}" + )) + })?, + ); + + // 2. Fetch existing profile document for ID + revision. + let (existing_doc_id, current_revision) = { + use dash_sdk::drive::query::WhereClause; + use dash_sdk::drive::query::WhereOperator; + use dash_sdk::platform::FetchMany; + use dpp::platform_value::platform_value; + + let query = dash_sdk::platform::DocumentQuery { + data_contract: Arc::clone(&dashpay_contract), + document_type_name: "profile".to_string(), + where_clauses: vec![WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: platform_value!(identity_id), + }], + order_by_clauses: vec![], + limit: 1, + start: None, + }; + + let docs = Document::fetch_many(&self.sdk, query) + .await + .map_err(PlatformWalletError::Sdk)?; + + match docs.into_values().next() { + Some(Some(doc)) => { + let id = doc.id(); + let rev = doc.revision().unwrap_or(INITIAL_REVISION); + (id, rev) + } + _ => { + return Err(PlatformWalletError::InvalidIdentityData( + "No existing profile document found to update".to_string(), + )); + } + } + }; + + // 3. Compute avatar hashes when bytes are provided. + let (avatar_hash, avatar_fingerprint) = if let Some(ref bytes) = input.avatar_bytes { + let hash = crate::wallet::identity::calculate_avatar_hash(bytes); + let fingerprint = crate::wallet::identity::calculate_dhash_fingerprint(bytes) + .map_err(PlatformWalletError::InvalidIdentityData)?; + (Some(hash), Some(fingerprint)) + } else { + // Preserve existing avatar fields from the local cache. + let wm = self.wallet_manager.read().await; + let (h, f) = wm + .get_wallet_info(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity(identity_id)) + .and_then(|m| m.dashpay_profile.as_ref()) + .map(|p| (p.avatar_hash, p.avatar_fingerprint)) + .unwrap_or((None, None)); + (h, f) + }; + + // 4. Build property map. + let mut properties = std::collections::BTreeMap::new(); + if let Some(ref name) = input.display_name { + properties.insert("displayName".to_string(), Value::Text(name.clone())); + } + if let Some(ref msg) = input.public_message { + properties.insert("publicMessage".to_string(), Value::Text(msg.clone())); + } + if let Some(ref url) = input.avatar_url { + properties.insert("avatarUrl".to_string(), Value::Text(url.clone())); + } + if let Some(hash) = avatar_hash { + properties.insert("avatarHash".to_string(), Value::Bytes32(hash)); + } + if let Some(fp) = avatar_fingerprint { + properties.insert("avatarFingerprint".to_string(), Value::Bytes(fp.to_vec())); + } + + // 5. Look up signing key. + let signing_key = { + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + managed + .identity + // DashPay profile create/update writes a document state + // transition, which DPP requires to be signed by a + // HIGH-or-stricter authentication key. MASTER is + // intentionally excluded — it's reserved for identity + // self-modification (update / key rotation / + // withdrawal) and rejected on document writes. + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "No HIGH or CRITICAL authentication key found on identity \ + (required for document state transitions)" + .to_string(), + ) + })? + }; + + let updated_document = Document::V0(DocumentV0 { + id: existing_doc_id, + owner_id: *identity_id, + properties, + revision: Some(current_revision + 1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }); + + let profile_document_type = dashpay_contract + .document_type_for_name("profile") + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to get profile document type: {e}" + )) + })? + .to_owned_document_type(); + + let _result_doc = updated_document + .put_to_platform_and_wait_for_response( + &self.sdk, + profile_document_type, + None, + signing_key, + None, + &SignerRef(signer), + None, + ) + .await + .map_err(PlatformWalletError::Sdk)?; + + let profile = crate::wallet::identity::DashPayProfile { + display_name: input.display_name, + bio: input.public_message.clone(), + avatar_url: input.avatar_url, + avatar_hash, + avatar_fingerprint, + public_message: input.public_message, + }; + + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { + managed.set_dashpay_profile(Some(profile.clone()), &self.persister); + } + } + } + + Ok(profile) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/register_from_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/register_from_addresses.rs index eeaab246fe3..521a9134035 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/register_from_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/register_from_addresses.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; +use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dpp::identity::signer::Signer; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; @@ -83,7 +84,7 @@ impl IdentityWallet { // Route through the auto-fetching SDK variant so the caller // doesn't need to maintain its own nonce cache — Platform is // always the source of truth at submit time. - let (identity, _address_infos) = identity + let (mut registered_identity, _address_infos) = identity .put_with_address_funding_fetching_nonces( &self.sdk, inputs, @@ -100,6 +101,24 @@ impl IdentityWallet { )) })?; + // The SDK return path for `put_with_address_funding_fetching_nonces` + // can hand back an `Identity` whose `public_keys` map is empty + // (the pre-broadcast stub doesn't echo the registered keys + // back). The caller-built placeholder we just submitted IS the + // canonical record of what the registration transition put on + // chain — its `public_keys` was serialized into the state + // transition that Platform just accepted. Copy it onto the + // returned identity so the in-memory `ManagedIdentity` (and + // every downstream auth-key check, e.g. the DPNS + // HIGH/CRITICAL gate) sees the same set of keys without + // waiting for the next identity-fetch round to repopulate + // them. The state transition itself signed those exact keys, + // so id (`Identifier`) reproducibility is preserved. + if registered_identity.public_keys().is_empty() { + registered_identity.set_public_keys(identity.public_keys().clone()); + } + let identity = registered_identity; + // Step 3: Add the identity to the local manager (with its HD // index) so subsequent operations route through it. { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs index 6c25edb59e9..bc5ee327f55 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs @@ -40,6 +40,20 @@ impl IdentityWallet { /// Convenience wrapper that uses `FundWithWallet` funding. For other /// funding methods, use [`register_identity_with_funding`](Self::register_identity_with_funding). /// + /// # Superseded — prefer the `_with_external_signer` flow + /// + /// This convenience wrapper threads down to + /// [`Self::register_identity_with_funding`] which constructs an + /// internal `IdentitySigner`. New callers should build their own + /// `Identity` placeholder + funding inputs and call + /// [`Self::register_identity_with_funding_external_signer`] + /// (asset-lock-funded) or + /// [`Self::register_from_addresses`] (DIP-17 funded) directly, + /// passing an external `&S: Signer`. The iOS + /// platform-wallet FFI uses + /// [`crate::register_identity_with_signer`-style](https://) entry + /// points for both. + /// /// # Arguments /// /// * `amount_duffs` - Amount of Dash (in duffs) to lock for the identity's @@ -65,6 +79,11 @@ impl IdentityWallet { /// Register a new identity on Platform with a specified funding method. /// + /// # Superseded — prefer [`Self::register_identity_with_funding_external_signer`] + /// + /// Same `IdentitySigner` deprecation rationale as + /// [`Self::register_identity`]. + /// /// High-level flow: /// 1. Obtain an asset lock proof according to the chosen `funding` method. /// 2. Generate `key_count` identity authentication keys at DIP-9 paths @@ -321,6 +340,200 @@ impl IdentityWallet { Ok(identity) } + /// Register a new asset-lock-funded identity on Platform using an + /// externally-supplied signer + caller-derived authentication keys. + /// + /// Mirrors [`Self::register_identity_with_funding`] but with the + /// `IdentitySigner` replaced by `&S: Signer` + /// and key derivation pushed to the caller. The caller must + /// provide: + /// + /// - `funding`: same `IdentityFundingMethod` as the legacy variant. + /// - `identity_index`: BIP-9 identity index. + /// - `keys_map`: the auth pubkeys the new identity will be created + /// with. Caller must derive these from the wallet seed (or from + /// iOS Keychain via `dash_sdk_derive_identity_keys_from_mnemonic`) + /// and persist the matching private keys to whatever store the + /// `signer` reads from. The first key (id=0) MUST be a MASTER / + /// AUTHENTICATION key — DPP's IdentityCreate state transition + /// itself must be signed by a MASTER-level identity key, and we + /// pin that role on id=0 by convention so callers don't need + /// protocol knowledge to assemble the map. The asset-lock-spend + /// signature on the same transition is a separate signature + /// keyed off `asset_lock_private_key`, supplied via `funding`. + /// - `signer`: external signer for the IdentityCreate transition's + /// per-key signatures. + /// + /// On success the new identity is added to the local manager and + /// each key is recorded with its derivation breadcrumb for the + /// persister callback (same bookkeeping as the legacy variant). + /// IS->CL fallback is retained. + pub async fn register_identity_with_funding_external_signer( + &self, + funding: IdentityFundingMethod, + identity_index: u32, + keys_map: BTreeMap, + signer: &S, + settings: Option, + ) -> Result + where + S: Signer + Send + Sync, + { + if keys_map.is_empty() { + return Err(PlatformWalletError::InvalidIdentityData( + "keys_map must contain at least one identity public key".to_string(), + )); + } + // Defensive: pin id=0 to MASTER+AUTHENTICATION at the FFI + // boundary so a malformed map fails fast here instead of + // surfacing as an opaque protocol-side rejection from + // `put_to_platform_and_wait_for_response`. + { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + 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(), + )); + } + } + } + + // Step 1: obtain asset lock proof + private key. + let (asset_lock_proof, asset_lock_private_key) = match funding { + IdentityFundingMethod::UseAssetLock { proof, private_key } => (proof, private_key), + IdentityFundingMethod::FundWithWallet { amount_duffs } => { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + let (proof, key, _out_point) = self + .asset_locks + .create_funded_asset_lock_proof( + amount_duffs, + 0, + AssetLockFundingType::IdentityRegistration, + identity_index, + ) + .await?; + (proof, key) + } + }; + + // Step 2: build the placeholder identity from caller-supplied keys. + let identity = Identity::V0(IdentityV0 { + id: Identifier::default(), + public_keys: keys_map, + balance: 0, + revision: 0, + }); + + // Step 3: submit, with IS->CL fallback on InstantSend rejection. + let proof_out_point = Self::out_point_from_proof(&asset_lock_proof); + + let identity = match identity + .put_to_platform_and_wait_for_response( + &self.sdk, + asset_lock_proof, + &asset_lock_private_key, + signer, + settings, + ) + .await + { + Ok(identity) => identity, + Err(e) if crate::error::is_instant_lock_proof_invalid(&e) => { + if let Some(out_point) = proof_out_point { + tracing::warn!( + "IS-lock proof rejected for identity registration (tx {}, external signer), \ + retrying with ChainLock proof", + out_point.txid + ); + let chain_proof = self + .asset_locks + .upgrade_to_chain_lock_proof(&out_point, Duration::from_secs(180)) + .await?; + identity + .put_to_platform_and_wait_for_response( + &self.sdk, + chain_proof, + &asset_lock_private_key, + signer, + settings, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to register identity on Platform (ChainLock retry): {}", + e + )) + })? + } else { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Failed to register identity on Platform: {}", + e + ))); + } + } + Err(e) => { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Failed to register identity on Platform: {}", + e + ))); + } + }; + + // Step 4: add to local manager + record key derivation + // breadcrumbs (mirrors the legacy variant exactly so the + // persister callback fires the same way regardless of which + // path produced the identity). + { + use dpp::identity::accessors::IdentityGettersV0; + + let mut wm = self.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + info.identity_manager.add_identity( + identity.clone(), + identity_index, + self.wallet_id, + &self.persister, + )?; + + let wallet_id = self.wallet_id; + let identity_id = identity.id(); + let public_keys: Vec<(KeyID, IdentityPublicKey)> = identity + .public_keys() + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect(); + + if let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) { + managed.wallet_id = Some(wallet_id); + for (key_id, pub_key) in public_keys { + let key_index = key_id; + managed.add_key( + pub_key, + Some((wallet_id, identity_index, key_index)), + &self.persister, + ); + } + } + } + + Ok(identity) + } + /// Register a new identity using an externally-provided identity, asset /// lock proof, and signer. /// diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs index b231646df17..c6940328576 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use dpp::identity::accessors::IdentitySettersV0; +use dpp::identity::signer::Signer; use dpp::prelude::Identifier; use dash_sdk::platform::transition::put_settings::PutSettings; @@ -12,7 +13,6 @@ use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use crate::error::PlatformWalletError; -use crate::wallet::platform_addresses::PlatformAddressWallet; use super::*; @@ -30,12 +30,15 @@ impl IdentityWallet { /// /// * `identity_id` - The identity to top up. /// * `inputs` - Map of platform addresses to credit amounts to spend. - /// * `platform_address_wallet` - The platform address wallet (provides signing). - pub async fn top_up_from_addresses( + /// * `address_signer` - Produces ECDSA signatures for the input + /// [`PlatformAddress`]es. Construction is the caller's concern — + /// seed-backed, hardware, FFI trampoline, whatever — the wallet + /// struct carries no key material itself. + pub async fn top_up_from_addresses + Send + Sync>( &self, identity_id: &Identifier, inputs: BTreeMap, - platform_address_wallet: &PlatformAddressWallet, + address_signer: &S, settings: Option, ) -> Result { let identity = { @@ -53,7 +56,7 @@ impl IdentityWallet { }; let (_address_infos, new_balance) = identity - .top_up_from_addresses(&self.sdk, inputs, platform_address_wallet, settings) + .top_up_from_addresses(&self.sdk, inputs, address_signer, settings) .await .map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs b/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs index fbb2bdee125..f0107c21bf3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs @@ -1,9 +1,13 @@ //! Transfer credits between identities. +use async_trait::async_trait; +use dpp::address_funds::AddressWitness; use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; +use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; +use dpp::ProtocolError; use dpp::identity::signer::Signer; @@ -14,6 +18,42 @@ use crate::error::PlatformWalletError; use super::*; +// Local borrowed-signer adapter — mirrors the one in `dpns.rs`. Lets +// callers hand a `&S: Signer` into APIs that demand +// an owned signer by generic bound. Same rationale: `Signer` is +// not implemented for `&T`, and we do not want to force callers to +// clone or `Arc`-wrap their `KeychainSigner` per call. +struct SignerRef<'a, S: ?Sized>(&'a S); + +impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignerRef") + } +} + +#[async_trait] +impl<'a, K, S> Signer for SignerRef<'a, S> +where + K: Send + Sync, + S: Signer + ?Sized + Send + Sync, +{ + async fn sign(&self, key: &K, data: &[u8]) -> Result { + self.0.sign(key, data).await + } + + async fn sign_create_witness( + &self, + key: &K, + data: &[u8], + ) -> Result { + self.0.sign_create_witness(key, data).await + } + + fn can_sign_with(&self, key: &K) -> bool { + self.0.can_sign_with(key) + } +} + // --------------------------------------------------------------------------- // Credit transfer // --------------------------------------------------------------------------- @@ -24,6 +64,16 @@ impl IdentityWallet { /// Submits an `IdentityCreditTransferTransition` to Platform that moves /// `amount` credits from `from_id` to `to_id`. /// + /// # Superseded — prefer [`Self::transfer_credits_with_external_signer`] + /// + /// This variant constructs an internal + /// [`IdentitySigner`](crate::wallet::signer::IdentitySigner) from the + /// wallet manager, which dies on watch-only wallets (no seed + /// Rust-side) and can deadlock the Tokio worker when its + /// derivation tries to `blocking_read` the wallet-manager lock + /// from inside a signing future. New callers should pass an + /// external `&S: Signer` instead. + /// /// # Arguments /// /// * `from_id` - The identifier of the sending identity (must be owned @@ -98,6 +148,87 @@ impl IdentityWallet { Ok(()) } + /// Transfer credits using an externally-supplied signer. + /// + /// Same shape as [`Self::transfer_credits`] but signing is routed + /// through the supplied `&S: Signer` instead of + /// the wallet's own [`IdentitySigner`](crate::wallet::signer::IdentitySigner). + /// Required for external-signable wallets (no seed Rust-side, e.g. + /// watch-only wallets where the seed lives in iOS Keychain) and + /// the architecturally correct path per `swift-sdk/CLAUDE.md`. + /// + /// The identity is still looked up from the in-process + /// `IdentityManager` so the local balance bookkeeping in + /// `ManagedIdentity` stays consistent with on-chain reality and + /// the persister observes the new balance via the snapshot + /// changeset. + pub async fn transfer_credits_with_external_signer( + &self, + from_id: &Identifier, + to_id: &Identifier, + amount: u64, + signer: &S, + settings: Option, + ) -> Result<(), PlatformWalletError> + where + S: Signer + Send + Sync, + { + let identity = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let manager = &info.identity_manager; + manager + .identity(from_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*from_id))? + }; + + let (sender_balance, _receiver_balance) = identity + .transfer_credits( + &self.sdk, + *to_id, + amount, + None, // signing_transfer_key_to_use + SignerRef(signer), + settings, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to transfer credits: {}", + e + )) + })?; + + // Mirror the local-state bookkeeping in `transfer_credits`: + // update the sender's balance and queue the snapshot so the + // change survives relaunch + reaches Swift via the persister. + { + let mut wm = self.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + if let Some(managed) = info.identity_manager.managed_identity_mut(from_id) { + managed.identity.set_balance(sender_balance); + if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { + tracing::error!( + identity = %from_id, + error = %e, + "Failed to persist identity balance update after transfer (external signer)" + ); + } + } + } + + Ok(()) + } + /// Transfer credits using an externally-provided identity and signer. /// /// Unlike [`transfer_credits`](Self::transfer_credits), this method does diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs index 43223f4ed79..041845bbdb1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs @@ -2,8 +2,14 @@ use std::collections::BTreeMap; +use async_trait::async_trait; +use dpp::address_funds::AddressWitness; use dpp::identity::accessors::IdentitySettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::IdentityPublicKey; +use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; +use dpp::ProtocolError; use dash_sdk::platform::transition::put_settings::PutSettings; use dash_sdk::platform::transition::transfer_to_addresses::TransferToAddresses; @@ -15,6 +21,38 @@ use crate::error::PlatformWalletError; use super::*; +// Borrowed-signer adapter — see `dpns.rs` for the pattern. +struct SignerRef<'a, S: ?Sized>(&'a S); + +impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignerRef") + } +} + +#[async_trait] +impl<'a, K, S> Signer for SignerRef<'a, S> +where + K: Send + Sync, + S: Signer + ?Sized + Send + Sync, +{ + async fn sign(&self, key: &K, data: &[u8]) -> Result { + self.0.sign(key, data).await + } + + async fn sign_create_witness( + &self, + key: &K, + data: &[u8], + ) -> Result { + self.0.sign_create_witness(key, data).await + } + + fn can_sign_with(&self, key: &K) -> bool { + self.0.can_sign_with(key) + } +} + // --------------------------------------------------------------------------- // Transfer credits to platform addresses // --------------------------------------------------------------------------- @@ -24,6 +62,12 @@ impl IdentityWallet { /// /// Uses the `TransferToAddresses` SDK trait. /// + /// # Superseded — prefer [`Self::transfer_credits_to_addresses_with_external_signer`] + /// + /// Same rationale as the `transfer_credits` deprecation: the + /// internal `IdentitySigner` path dies on watch-only wallets and + /// can deadlock the Tokio worker. + /// /// # Arguments /// /// * `identity_id` - The sending identity (must be owned by this wallet). @@ -99,4 +143,78 @@ impl IdentityWallet { Ok(new_balance) } + + /// Transfer credits from an identity to multiple platform + /// addresses using an externally-supplied signer. + /// + /// Same shape as [`Self::transfer_credits_to_addresses`] but + /// signing is routed through the supplied + /// `&S: Signer`. Required for + /// external-signable wallets. + pub async fn transfer_credits_to_addresses_with_external_signer( + &self, + identity_id: &Identifier, + recipient_addresses: BTreeMap, + signer: &S, + settings: Option, + ) -> Result + where + S: Signer + Send + Sync, + { + let identity = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let manager = &info.identity_manager; + manager + .identity(identity_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? + }; + + let (_address_infos, new_balance) = identity + .transfer_credits_to_addresses( + &self.sdk, + recipient_addresses, + None, // signing_transfer_key_to_use + &SignerRef(signer), + settings, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to transfer credits to addresses: {}", + e + )) + })?; + + // Mirror the local-state bookkeeping in the legacy variant. + { + let mut wm = self.wallet_manager.write().await; + let info_guard = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + if let Some(managed) = info_guard + .identity_manager + .managed_identity_mut(identity_id) + { + managed.identity.set_balance(new_balance); + if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { + tracing::error!( + identity = %identity_id, + error = %e, + "Failed to persist identity balance update after \ + transfer_to_addresses (external signer)" + ); + } + } + } + + Ok(new_balance) + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 601901f4127..b8c58049ec9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -1,5 +1,7 @@ //! Mutate an identity's public-key set. +use async_trait::async_trait; +use dpp::address_funds::AddressWitness; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::accessors::IdentitySettersV0; @@ -9,7 +11,9 @@ use dpp::identity::IdentityPublicKey; use dpp::identity::KeyType; use dpp::identity::Purpose; use dpp::identity::SecurityLevel; +use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; +use dpp::ProtocolError; use dpp::identity::signer::Signer; @@ -19,6 +23,38 @@ use crate::error::PlatformWalletError; use super::*; +// Borrowed-signer adapter — see `dpns.rs` for the same pattern. +struct SignerRef<'a, S: ?Sized>(&'a S); + +impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignerRef") + } +} + +#[async_trait] +impl<'a, K, S> Signer for SignerRef<'a, S> +where + K: Send + Sync, + S: Signer + ?Sized + Send + Sync, +{ + async fn sign(&self, key: &K, data: &[u8]) -> Result { + self.0.sign(key, data).await + } + + async fn sign_create_witness( + &self, + key: &K, + data: &[u8], + ) -> Result { + self.0.sign_create_witness(key, data).await + } + + fn can_sign_with(&self, key: &K) -> bool { + self.0.can_sign_with(key) + } +} + // --------------------------------------------------------------------------- // Identity update (add/disable keys) // --------------------------------------------------------------------------- @@ -29,6 +65,12 @@ impl IdentityWallet { /// Builds an `IdentityUpdateTransition`, signs it with the identity's /// master key, and broadcasts it to Platform. /// + /// # Superseded — prefer [`Self::update_identity_with_external_signer`] + /// + /// Same rationale as the `transfer_credits` deprecation: the + /// internal `IdentitySigner` path dies on watch-only wallets and + /// can deadlock the Tokio worker. + /// /// # Arguments /// /// * `identity_id` - The identity to update. @@ -108,6 +150,7 @@ impl IdentityWallet { self.sdk.version(), None, ) + .await .map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to create identity update transition: {}", @@ -129,6 +172,126 @@ impl IdentityWallet { Ok(()) } + /// Update an identity using an externally-supplied signer. + /// + /// Same shape as [`Self::update_identity`] but signing is routed + /// through the supplied `&S: Signer`. Required + /// for external-signable wallets. + /// + /// The identity is still looked up from the in-process + /// `IdentityManager` so we can pick the MASTER auth key the + /// identity-update state transition requires (DPP gates this on + /// MASTER specifically — HIGH/CRITICAL aren't accepted). + /// + /// NOTE: callers that ADD keys via `add_public_keys` are + /// responsible for pre-persisting the new keys' private material + /// to whatever store the supplied signer reads from (iOS Keychain + /// in the typical case). The signer here only signs the update + /// transition itself; it does not derive the new keys. + /// + /// CACHE INVARIANT: this function does NOT refresh the in-process + /// `IdentityManager` after a successful broadcast. The local + /// cached `Identity` keeps the pre-update revision and key set + /// until the caller invokes [`Self::refresh_identity`] (or the + /// next sync round). A subsequent call to this function for the + /// same identity without an intervening refresh will reuse the + /// stale revision and Platform will reject the duplicate. This + /// matches the behaviour of the legacy [`Self::update_identity`] + /// path; it is documented here rather than fixed because the + /// refresh requires a wallet-manager write lock that may already + /// be held higher in the call stack. + pub async fn update_identity_with_external_signer( + &self, + identity_id: &Identifier, + add_public_keys: Vec, + disable_public_keys: Vec, + signer: &S, + settings: Option, + ) -> Result<(), PlatformWalletError> + where + S: Signer + Send + Sync, + { + use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + use dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; + use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; + use dpp::state_transition::proof_result::StateTransitionProofResult; + + let mut identity = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let manager = &info.identity_manager; + manager + .identity(identity_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? + }; + + // Increment revision for the update transition. + let original_revision = identity.revision(); + identity.set_revision(original_revision + 1); + + // Pick the MASTER signing key — DPP requires identity update + // transitions to be authorized by MASTER specifically. + let master_key_id = identity + .public_keys() + .iter() + .find(|(_, key)| { + key.purpose() == Purpose::AUTHENTICATION + && key.security_level() == SecurityLevel::MASTER + && key.key_type() == KeyType::ECDSA_SECP256K1 + }) + .map(|(id, _)| *id) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "No signable master key found on identity".to_string(), + ) + })?; + + let identity_nonce = self + .sdk + .get_identity_nonce(identity.id(), true, settings) + .await?; + + let user_fee_increase = settings + .and_then(|s| s.user_fee_increase) + .unwrap_or_default(); + + let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( + &identity, + &master_key_id, + add_public_keys, + disable_public_keys, + identity_nonce, + user_fee_increase, + &SignerRef(signer), + self.sdk.version(), + None, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to create identity update transition: {}", + e + )) + })?; + + state_transition + .broadcast_and_wait::(&self.sdk, settings) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to broadcast identity update: {}", + e + )) + })?; + + Ok(()) + } + /// Update an identity using an externally-provided identity and signer. /// /// Unlike [`update_identity`](Self::update_identity), this method does @@ -173,6 +336,7 @@ impl IdentityWallet { self.sdk.version(), None, ) + .await .map_err(dash_sdk::Error::Protocol)?; // Broadcast and wait for confirmation. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs index ccf3cf18c18..f57f98d9d51 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs @@ -1,10 +1,14 @@ //! Withdraw credits from an identity. +use async_trait::async_trait; use dashcore::Address as DashAddress; +use dpp::address_funds::AddressWitness; use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; +use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; +use dpp::ProtocolError; use dpp::identity::signer::Signer; @@ -15,6 +19,40 @@ use crate::error::PlatformWalletError; use super::*; +// Borrowed-signer adapter — see `dpns.rs`/`transfer.rs` for the same +// pattern. Lets a `&S: Signer` satisfy APIs that +// take an owned signer by generic bound. +struct SignerRef<'a, S: ?Sized>(&'a S); + +impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignerRef") + } +} + +#[async_trait] +impl<'a, K, S> Signer for SignerRef<'a, S> +where + K: Send + Sync, + S: Signer + ?Sized + Send + Sync, +{ + async fn sign(&self, key: &K, data: &[u8]) -> Result { + self.0.sign(key, data).await + } + + async fn sign_create_witness( + &self, + key: &K, + data: &[u8], + ) -> Result { + self.0.sign_create_witness(key, data).await + } + + fn can_sign_with(&self, key: &K) -> bool { + self.0.can_sign_with(key) + } +} + // --------------------------------------------------------------------------- // Withdrawal // --------------------------------------------------------------------------- @@ -26,6 +64,13 @@ impl IdentityWallet { /// the specified amount (in platform credits) from the identity back to /// a Core chain address. /// + /// # Superseded — prefer [`Self::withdraw_credits_with_external_signer`] + /// + /// Same rationale as the `transfer_credits` deprecation: the + /// internal `IdentitySigner` path dies on watch-only wallets and + /// can deadlock the Tokio worker. New callers should pass an + /// external `&S: Signer`. + /// /// # Arguments /// /// * `identity_id` - The identifier of the identity to withdraw from. @@ -93,6 +138,83 @@ impl IdentityWallet { Ok(()) } + /// Withdraw credits using an externally-supplied signer. + /// + /// Same shape as [`Self::withdraw_credits`] but signing is routed + /// through the supplied `&S: Signer` instead + /// of the wallet's own `IdentitySigner`. Required for + /// external-signable wallets (no seed Rust-side) and the + /// architecturally correct path per `swift-sdk/CLAUDE.md`. + /// + /// The identity is still looked up from the in-process + /// `IdentityManager` so the local balance bookkeeping in + /// `ManagedIdentity` stays consistent. + pub async fn withdraw_credits_with_external_signer( + &self, + identity_id: &Identifier, + amount: u64, + to_address: &DashAddress, + signer: &S, + settings: Option, + ) -> Result<(), PlatformWalletError> + where + S: Signer + Send + Sync, + { + let identity = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let manager = &info.identity_manager; + manager + .identity(identity_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? + }; + + let new_balance = identity + .withdraw( + &self.sdk, + Some(to_address.clone()), + amount, + None, // core_fee_per_byte + None, // signing_withdrawal_key_to_use + SignerRef(signer), + settings, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to withdraw credits: {}", + e + )) + })?; + + // Mirror the local-state bookkeeping in `withdraw_credits`. + { + let mut wm = self.wallet_manager.write().await; + let info_guard = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + if let Some(managed) = info_guard.identity_manager.identity_mut(identity_id) { + managed.identity.set_balance(new_balance); + if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { + tracing::error!( + identity = %identity_id, + error = %e, + "Failed to persist identity balance update after withdraw (external signer)" + ); + } + } + } + + Ok(()) + } + /// Withdraw credits using an externally-provided identity and signer. /// /// Unlike [`withdraw_credits`](Self::withdraw_credits), this method does diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs index 3b60dd0bcda..663e58f0639 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs @@ -65,7 +65,7 @@ pub enum IdentityLocation { /// Manages identities for a platform wallet. /// /// See the module docs for the bucket layout. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct IdentityManager { /// Identities the wallet observes but cannot sign for, keyed by /// identity id. Replaces the old `WatchedIdentity` bucket — the @@ -97,16 +97,6 @@ pub struct IdentityManager { location_index: BTreeMap, } -impl Default for IdentityManager { - fn default() -> Self { - Self { - out_of_wallet_identities: BTreeMap::new(), - wallet_identities: BTreeMap::new(), - location_index: BTreeMap::new(), - } - } -} - impl From for IdentityManager { fn from(state: IdentityManagerStartState) -> Self { let IdentityManagerStartState { @@ -312,7 +302,7 @@ mod tests { let removed = manager.remove_identity(&identity_id, &p).unwrap(); assert_eq!(removed.id(), identity_id); - assert!(manager.wallet_identities.get(&wallet_id).is_none()); + assert!(!manager.wallet_identities.contains_key(&wallet_id)); assert_eq!(manager.identity_count(), 0); } diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs index 0a8d223e987..927b6d0d575 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/fund_from_asset_lock.rs @@ -4,6 +4,7 @@ use dash_sdk::platform::transition::top_up_address::TopUpAddress; use dashcore::PrivateKey; use dpp::address_funds::{AddressFundsFeeStrategy, PlatformAddress}; use dpp::fee::Credits; +use dpp::identity::signer::Signer; use dpp::prelude::AssetLockProof; use key_wallet::PlatformP2PKHAddress; use std::collections::BTreeMap; @@ -21,13 +22,17 @@ impl PlatformAddressWallet { /// * `asset_lock_proof` - Proof of the asset lock transaction on Core chain. /// * `asset_lock_private_key` - Private key corresponding to the asset lock. /// * `fee_strategy` - How the fee should be deducted. - pub async fn fund_from_asset_lock( + /// * `address_signer` - Signs each previously-funded input address's + /// contribution. The wallet struct itself carries no key material. + #[allow(clippy::too_many_arguments)] + pub async fn fund_from_asset_lock + Send + Sync>( &self, account_index: u32, addresses: BTreeMap>, asset_lock_proof: AssetLockProof, asset_lock_private_key: PrivateKey, fee_strategy: AddressFundsFeeStrategy, + address_signer: &S, ) -> Result { if addresses.is_empty() { return Err(PlatformWalletError::AddressOperation( @@ -84,7 +89,7 @@ impl PlatformAddressWallet { asset_lock_proof, asset_lock_private_key, fee_strategy, - self, + address_signer, None, ) .await?; diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs index 2c2063e2c17..d216228284a 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs @@ -8,7 +8,6 @@ pub use dpp::prelude::AddressNonce; mod fund_from_asset_lock; pub(crate) mod provider; -mod signing; mod sync; mod transfer; mod wallet; diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/signing.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/signing.rs deleted file mode 100644 index 57db4750362..00000000000 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/signing.rs +++ /dev/null @@ -1,117 +0,0 @@ -use dpp::address_funds::{AddressWitness, PlatformAddress}; -use dpp::identity::signer::Signer; -use dpp::platform_value::BinaryData; -use dpp::ProtocolError; -use key_wallet::PlatformP2PKHAddress; -use zeroize::Zeroizing; - -use crate::error::PlatformWalletError; -use crate::wallet::PlatformAddressWallet; - -impl PlatformAddressWallet { - /// Find the private key for a platform address by searching all platform - /// payment accounts' address pools. - /// - /// Returns the raw private key bytes wrapped in [`Zeroizing`] so they are - /// automatically wiped from memory when the value is dropped. - pub(crate) async fn find_private_key_for_platform_address( - &self, - p2pkh: &PlatformP2PKHAddress, - ) -> Result, PlatformWalletError> { - let dashcore_addr = p2pkh.to_address(self.sdk.network); - - let wm = self.wallet_manager.read().await; - let (wallet, info) = wm.get_wallet_and_info(&self.wallet_id).ok_or_else(|| { - PlatformWalletError::WalletNotFound(format!( - "Wallet {:?} not found in wallet manager", - hex::encode(self.wallet_id) - )) - })?; - - // Search all platform payment accounts for the address. - let mut found_path = None; - for account in info.core_wallet.accounts.platform_payment_accounts.values() { - if let Some(addr_info) = account.addresses.address_info(&dashcore_addr) { - found_path = Some(addr_info.path.clone()); - break; - } - } - - let path = - found_path.ok_or_else(|| PlatformWalletError::AddressNotFound(format!("{}", p2pkh)))?; - - let secret_key = wallet.derive_private_key(&path).map_err(|e| { - PlatformWalletError::KeyDerivation(format!( - "Failed to derive private key for {}: {}", - p2pkh, e - )) - })?; - - Ok(Zeroizing::new(secret_key.secret_bytes())) - } -} - -impl Signer for PlatformAddressWallet { - fn sign( - &self, - platform_address: &PlatformAddress, - data: &[u8], - ) -> Result { - let PlatformAddress::P2pkh(hash) = platform_address else { - return Err(ProtocolError::Generic( - "Only P2PKH Platform addresses are supported for signing".to_string(), - )); - }; - let p2pkh = PlatformP2PKHAddress::new(*hash); - let handle = tokio::runtime::Handle::current(); - let private_key_bytes = tokio::task::block_in_place(|| { - handle.block_on(self.find_private_key_for_platform_address(&p2pkh)) - }) - .map_err(|e| ProtocolError::Generic(e.to_string()))?; - - let signature = dashcore::signer::sign(data, private_key_bytes.as_ref()) - .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; - - Ok(BinaryData::new(signature.to_vec())) - } - - fn sign_create_witness( - &self, - platform_address: &PlatformAddress, - data: &[u8], - ) -> Result { - let PlatformAddress::P2pkh(hash) = platform_address else { - return Err(ProtocolError::Generic( - "Only P2PKH Platform addresses are supported for signing".to_string(), - )); - }; - let p2pkh = PlatformP2PKHAddress::new(*hash); - let handle = tokio::runtime::Handle::current(); - let private_key_bytes = tokio::task::block_in_place(|| { - handle.block_on(self.find_private_key_for_platform_address(&p2pkh)) - }) - .map_err(|e| ProtocolError::Generic(e.to_string()))?; - - let signature = dashcore::signer::sign(data, private_key_bytes.as_ref()) - .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; - - Ok(AddressWitness::P2pkh { - signature: BinaryData::new(signature.to_vec()), - }) - } - - fn can_sign_with(&self, platform_address: &PlatformAddress) -> bool { - let PlatformAddress::P2pkh(hash) = platform_address else { - return false; - }; - let p2pkh = PlatformP2PKHAddress::new(*hash); - let Ok(handle) = tokio::runtime::Handle::try_current() else { - return false; - }; - tokio::task::block_in_place(|| { - handle - .block_on(self.find_private_key_for_platform_address(&p2pkh)) - .is_ok() - }) - } -} diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs index d9e6812d15b..8af37949e3b 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use dpp::address_funds::{AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, PlatformAddress}; use dpp::fee::Credits; +use dpp::identity::signer::Signer; use dpp::state_transition::address_funds_transfer_transition::AddressFundsTransferTransition; use dpp::version::PlatformVersion; use dpp::version::LATEST_PLATFORM_VERSION; @@ -21,13 +22,20 @@ impl PlatformAddressWallet { /// /// If `platform_version` is `None`, the latest platform version's fee /// schedule is used for fee estimation during auto-selection. - pub async fn transfer( + /// + /// `address_signer` produces ECDSA signatures for the input + /// [`PlatformAddress`]es. The wallet struct itself carries no key + /// material — callers supply a seed-backed, hardware, or + /// FFI-trampoline signer per their environment (iOS routes through + /// `KeychainSigner` via `VTableSigner`). + pub async fn transfer + Send + Sync>( &self, account_index: u32, input_selection: InputSelection, outputs: BTreeMap, fee_strategy: AddressFundsFeeStrategy, platform_version: Option<&PlatformVersion>, + address_signer: &S, ) -> Result { if outputs.is_empty() { return Err(PlatformWalletError::AddressOperation( @@ -45,7 +53,7 @@ impl PlatformAddressWallet { )); } self.sdk - .transfer_address_funds(inputs, outputs, fee_strategy, self, None) + .transfer_address_funds(inputs, outputs, fee_strategy, address_signer, None) .await? } InputSelection::ExplicitWithNonces(inputs) => { @@ -55,7 +63,13 @@ impl PlatformAddressWallet { )); } self.sdk - .transfer_address_funds_with_nonce(inputs, outputs, fee_strategy, self, None) + .transfer_address_funds_with_nonce( + inputs, + outputs, + fee_strategy, + address_signer, + None, + ) .await? } InputSelection::Auto => { @@ -63,7 +77,7 @@ impl PlatformAddressWallet { .auto_select_inputs(account_index, &outputs, &fee_strategy, version) .await?; self.sdk - .transfer_address_funds(inputs, outputs, fee_strategy, self, None) + .transfer_address_funds(inputs, outputs, fee_strategy, address_signer, None) .await? } }; diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs index 4c418f6696a..61695829700 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use dpp::address_funds::{AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, PlatformAddress}; use dpp::fee::Credits; use dpp::identity::core_script::CoreScript; +use dpp::identity::signer::Signer; use dpp::state_transition::address_credit_withdrawal_transition::AddressCreditWithdrawalTransition; use dpp::version::PlatformVersion; use dpp::version::LATEST_PLATFORM_VERSION; @@ -22,8 +23,13 @@ impl PlatformAddressWallet { /// /// If `platform_version` is `None`, the latest platform version's fee /// schedule is used for fee estimation during auto-selection. + /// + /// `address_signer` produces ECDSA signatures for the input + /// [`PlatformAddress`]es; the wallet struct carries no key material + /// itself (see the type-level docs on + /// [`PlatformAddressWallet`]). #[allow(clippy::too_many_arguments)] - pub async fn withdraw( + pub async fn withdraw + Send + Sync>( &self, account_index: u32, input_selection: InputSelection, @@ -31,6 +37,7 @@ impl PlatformAddressWallet { core_fee_per_byte: u32, fee_strategy: AddressFundsFeeStrategy, platform_version: Option<&PlatformVersion>, + address_signer: &S, ) -> Result { // Validate that the output script is a supported type (P2PKH or P2SH). if !output_script.is_p2pkh() && !output_script.is_p2sh() { @@ -56,7 +63,7 @@ impl PlatformAddressWallet { core_fee_per_byte, Pooling::Never, output_script, - self, + address_signer, None, ) .await? @@ -75,7 +82,7 @@ impl PlatformAddressWallet { core_fee_per_byte, Pooling::Never, output_script, - self, + address_signer, None, ) .await? @@ -92,7 +99,7 @@ impl PlatformAddressWallet { core_fee_per_byte, Pooling::Never, output_script, - self, + address_signer, None, ) .await? diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 514bd519272..fb6d6ea41da 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -87,7 +87,10 @@ impl ShieldedWallet { info!("Shield credits: {} credits, building proof...", amount,); - // Build the state transition using the DPP builder + // Build the state transition using the DPP builder. + // `build_shield_transition` is async (cascade from the dpp + // `Signer` trait being made async upstream); await before + // mapping the error. let state_transition = build_shield_transition( &recipient_addr, amount, @@ -99,6 +102,7 @@ impl ShieldedWallet { [0u8; 36], // empty memo self.sdk.version(), ) + .await .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; // Broadcast diff --git a/packages/rs-platform-wallet/src/wallet/signer.rs b/packages/rs-platform-wallet/src/wallet/signer.rs index dbdcec0df36..39480dcb781 100644 --- a/packages/rs-platform-wallet/src/wallet/signer.rs +++ b/packages/rs-platform-wallet/src/wallet/signer.rs @@ -2,6 +2,7 @@ use std::sync::Arc; +use async_trait::async_trait; use dpp::address_funds::AddressWitness; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::signer::Signer; @@ -57,11 +58,14 @@ impl IdentitySigner { /// wiped from memory when the value is dropped. /// /// The shared lock is acquired and released within this method. - fn derive_private_key_bytes( + /// Uses async `read().await` — calling this from inside a Tokio + /// worker (which the `Signer::sign` impl below + /// is) cannot use `blocking_read()` without panicking the runtime. + async fn derive_private_key_bytes( &self, identity_public_key: &IdentityPublicKey, ) -> Result, ProtocolError> { - let wm = self.wallet_manager.blocking_read(); + let wm = self.wallet_manager.read().await; let wallet = wm.get_wallet(&self.wallet_id).ok_or_else(|| { ProtocolError::Generic("Wallet not found in wallet manager".to_string()) })?; @@ -75,13 +79,14 @@ impl IdentitySigner { } } +#[async_trait] impl Signer for IdentitySigner { - fn sign( + async fn sign( &self, identity_public_key: &IdentityPublicKey, data: &[u8], ) -> Result { - let private_key_bytes = self.derive_private_key_bytes(identity_public_key)?; + let private_key_bytes = self.derive_private_key_bytes(identity_public_key).await?; match identity_public_key.key_type() { KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { @@ -130,12 +135,12 @@ impl Signer for IdentitySigner { } } - fn sign_create_witness( + async fn sign_create_witness( &self, identity_public_key: &IdentityPublicKey, data: &[u8], ) -> Result { - let signature = self.sign(identity_public_key, data)?; + let signature = self.sign(identity_public_key, data).await?; match identity_public_key.key_type() { KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { @@ -148,8 +153,15 @@ impl Signer for IdentitySigner { } } - fn can_sign_with(&self, identity_public_key: &IdentityPublicKey) -> bool { - self.derive_private_key_bytes(identity_public_key).is_ok() + fn can_sign_with(&self, _identity_public_key: &IdentityPublicKey) -> bool { + // Optimistic: any wallet-internal signer is assumed to be able + // to derive ANY identity key it's asked about. The real + // failure mode (watch-only wallet, missing seed, wrong + // network) surfaces from the actual `sign` call. Cannot do a + // real probe here — `derive_private_key_bytes` is async, and + // this trait method is sync, and bridging via `block_on` from + // inside a Tokio worker panics the runtime. + true } } @@ -199,12 +211,15 @@ impl ManagedIdentitySigner { } /// Derive private key bytes for a given identity public key by - /// re-deriving from the wallet seed at the DIP-9 path. - fn derive_private_key_bytes( + /// re-deriving from the wallet seed at the DIP-9 path. Async to + /// avoid the Tokio "blocking from within a runtime" panic — the + /// `Signer::sign` impl that calls this runs on + /// a Tokio worker thread. + async fn derive_private_key_bytes( &self, identity_public_key: &IdentityPublicKey, ) -> Result, ProtocolError> { - let wm = self.wallet_manager.blocking_read(); + let wm = self.wallet_manager.read().await; let wallet = wm.get_wallet(&self.wallet_id).ok_or_else(|| { ProtocolError::Generic("Wallet not found in wallet manager".to_string()) })?; @@ -218,13 +233,14 @@ impl ManagedIdentitySigner { } } +#[async_trait] impl Signer for ManagedIdentitySigner { - fn sign( + async fn sign( &self, identity_public_key: &IdentityPublicKey, data: &[u8], ) -> Result { - let private_key_bytes = self.derive_private_key_bytes(identity_public_key)?; + let private_key_bytes = self.derive_private_key_bytes(identity_public_key).await?; match identity_public_key.key_type() { KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { @@ -273,12 +289,12 @@ impl Signer for ManagedIdentitySigner { } } - fn sign_create_witness( + async fn sign_create_witness( &self, identity_public_key: &IdentityPublicKey, data: &[u8], ) -> Result { - let signature = self.sign(identity_public_key, data)?; + let signature = self.sign(identity_public_key, data).await?; match identity_public_key.key_type() { KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { @@ -291,8 +307,15 @@ impl Signer for ManagedIdentitySigner { } } - fn can_sign_with(&self, identity_public_key: &IdentityPublicKey) -> bool { - self.derive_private_key_bytes(identity_public_key).is_ok() + fn can_sign_with(&self, _identity_public_key: &IdentityPublicKey) -> bool { + // Optimistic: any wallet-internal signer is assumed to be able + // to derive ANY identity key it's asked about. The real + // failure mode (watch-only wallet, missing seed, wrong + // network) surfaces from the actual `sign` call. Cannot do a + // real probe here — `derive_private_key_bytes` is async, and + // this trait method is sync, and bridging via `block_on` from + // inside a Tokio worker panics the runtime. + true } } @@ -305,265 +328,13 @@ impl std::fmt::Debug for ManagedIdentitySigner { } } -// --------------------------------------------------------------------------- -// Seed-backed signers (no wallet key material required) -// --------------------------------------------------------------------------- -// -// These signers derive private keys from a BIP-39 seed held in memory for -// the duration of a single operation (typically `register_from_addresses`). -// They exist so that watch-only / external-signable wallets — which carry -// no key material in `Wallet::wallet_type` — can still drive flows that -// need actual signatures. The seed is the caller's responsibility: it -// comes from iOS Keychain, travels through FFI inside a `Zeroizing` -// buffer, and is dropped as soon as the operation completes. - -use dashcore::secp256k1::Secp256k1; -use dpp::address_funds::PlatformAddress; -use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, KeyDerivationType}; -use key_wallet::dip9::{ - IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, -}; -use key_wallet::PlatformP2PKHAddress; - -use crate::wallet::platform_addresses::PlatformAddressWallet; - -/// Build the DIP-9 identity authentication path -/// `m/9'/COIN'/5'/0'/ECDSA'/identity_index'/key_index'`. -fn dip9_identity_auth_path( - network: Network, - identity_index: u32, - key_index: u32, -) -> Result { - let base = match network { - Network::Mainnet => IDENTITY_AUTHENTICATION_PATH_MAINNET, - _ => IDENTITY_AUTHENTICATION_PATH_TESTNET, - }; - let key_type_index: u32 = KeyDerivationType::ECDSA.into(); - - Ok(DerivationPath::from(base).extend([ - ChildNumber::from_hardened_idx(key_type_index) - .map_err(|e| ProtocolError::Generic(format!("Invalid key type index: {}", e)))?, - ChildNumber::from_hardened_idx(identity_index) - .map_err(|e| ProtocolError::Generic(format!("Invalid identity index: {}", e)))?, - ChildNumber::from_hardened_idx(key_index) - .map_err(|e| ProtocolError::Generic(format!("Invalid key index: {}", e)))?, - ])) -} - -/// Derive a 32-byte ECDSA private key at `path` from a BIP-39 seed. -fn derive_ecdsa_bytes_from_seed( - seed: &[u8], - network: Network, - path: &DerivationPath, -) -> Result, ProtocolError> { - let master = ExtendedPrivKey::new_master(network, seed) - .map_err(|e| ProtocolError::Generic(format!("Failed to derive master key: {}", e)))?; - let secp = Secp256k1::new(); - let derived = master - .derive_priv(&secp, path) - .map_err(|e| ProtocolError::Generic(format!("Failed to derive key at path: {}", e)))?; - Ok(Zeroizing::new(derived.private_key.secret_bytes())) -} - -/// Sign `data` with an ECDSA secp256k1 private key and return a -/// `BinaryData`-wrapped compact signature (the form DPP expects). -fn sign_ecdsa_with_bytes( - data: &[u8], - secret_bytes: &[u8; 32], -) -> Result { - let signature = dashcore::signer::sign(data, secret_bytes) - .map_err(|e| ProtocolError::Generic(format!("ECDSA signing failed: {}", e)))?; - Ok(BinaryData::new(signature.to_vec())) -} - -/// `Signer` impl backed by a BIP-39 seed. -/// -/// Derives the DIP-9 identity authentication key on every `sign` call -/// (master key HMAC is ~microseconds). Drop the signer as soon as the -/// operation completes so the seed is scrubbed. -pub struct SeedBackedIdentitySigner { - seed: Zeroizing>, - network: Network, - identity_index: u32, -} - -impl SeedBackedIdentitySigner { - /// Construct from an already-computed BIP-39 seed (typically 64 - /// bytes from `Mnemonic::to_seed`). The seed is cloned into a - /// `Zeroizing` buffer owned by the signer. - pub fn new(seed: &[u8], network: Network, identity_index: u32) -> Self { - Self { - seed: Zeroizing::new(seed.to_vec()), - network, - identity_index, - } - } -} - -impl Signer for SeedBackedIdentitySigner { - fn sign( - &self, - identity_public_key: &IdentityPublicKey, - data: &[u8], - ) -> Result { - // Identity auth keys only — the DIP-9 path is keyed by the - // `IdentityPublicKey.id` (KeyID == key_index on our tree). - match identity_public_key.key_type() { - KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { - let path = dip9_identity_auth_path( - self.network, - self.identity_index, - identity_public_key.id(), - )?; - let secret = derive_ecdsa_bytes_from_seed(&self.seed, self.network, &path)?; - sign_ecdsa_with_bytes(data, &secret) - } - other => Err(ProtocolError::Generic(format!( - "Seed-backed signer does not support key type {:?}", - other - ))), - } - } - - fn sign_create_witness( - &self, - identity_public_key: &IdentityPublicKey, - data: &[u8], - ) -> Result { - let signature = self.sign(identity_public_key, data)?; - match identity_public_key.key_type() { - KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { - Ok(AddressWitness::P2pkh { signature }) - } - other => Err(ProtocolError::Generic(format!( - "Key type {:?} is not supported for address witnesses", - other - ))), - } - } - - fn can_sign_with(&self, identity_public_key: &IdentityPublicKey) -> bool { - matches!( - identity_public_key.key_type(), - KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 - ) - } -} - -impl std::fmt::Debug for SeedBackedIdentitySigner { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SeedBackedIdentitySigner") - .field("network", &self.network) - .field("identity_index", &self.identity_index) - .finish() - } -} - -/// `Signer` impl backed by a BIP-39 seed. -/// -/// Looks up the DIP-17 derivation path for the P2PKH hash on the -/// wrapped [`PlatformAddressWallet`] (the pool still knows which path -/// produced which address), then derives the private key from the -/// seed using that path. -/// -/// The wallet is held by value — [`PlatformAddressWallet`] is -/// `Clone` and all its internal state lives behind `Arc`s, so this -/// is a cheap refcount bump that frees the signer from any -/// lifetime tied to the caller's stack. That in turn lets it be -/// captured by a `'static + Send` future (e.g. one handed to -/// `tokio::spawn`). -pub struct SeedBackedPlatformAddressSigner { - seed: Zeroizing>, - network: Network, - wallet: PlatformAddressWallet, -} - -impl SeedBackedPlatformAddressSigner { - pub fn new(seed: &[u8], network: Network, wallet: PlatformAddressWallet) -> Self { - Self { - seed: Zeroizing::new(seed.to_vec()), - network, - wallet, - } - } - - /// Synchronously look up the DIP-17 path for a P2PKH platform - /// address by scanning all platform-payment accounts in the - /// wallet manager. Mirrors the path-lookup portion of - /// [`PlatformAddressWallet::find_private_key_for_platform_address`] - /// but stops before the privkey-derivation step. - fn path_for(&self, p2pkh: &PlatformP2PKHAddress) -> Result { - let dashcore_addr = p2pkh.to_address(self.wallet.sdk.network); - let handle = tokio::runtime::Handle::current(); - let found = tokio::task::block_in_place(|| { - handle.block_on(async { - let wm = self.wallet.wallet_manager.read().await; - // `PlatformWalletInfo` is not `Clone`, so resolve the - // path while the read guard is still held and copy - // out only the `DerivationPath`. - wm.get_wallet_info(&self.wallet.wallet_id).and_then(|info| { - info.core_wallet - .accounts - .platform_payment_accounts - .values() - .find_map(|account| { - account - .addresses - .address_info(&dashcore_addr) - .map(|ai| ai.path.clone()) - }) - }) - }) - }); - found.ok_or_else(|| { - ProtocolError::Generic(format!( - "Platform address {} not found in wallet's address pools", - p2pkh - )) - }) - } -} - -impl Signer for SeedBackedPlatformAddressSigner { - fn sign( - &self, - platform_address: &PlatformAddress, - data: &[u8], - ) -> Result { - let PlatformAddress::P2pkh(hash) = platform_address else { - return Err(ProtocolError::Generic( - "Only P2PKH Platform addresses are supported for signing".to_string(), - )); - }; - let p2pkh = PlatformP2PKHAddress::new(*hash); - let path = self.path_for(&p2pkh)?; - let secret = derive_ecdsa_bytes_from_seed(&self.seed, self.network, &path)?; - sign_ecdsa_with_bytes(data, &secret) - } - - fn sign_create_witness( - &self, - platform_address: &PlatformAddress, - data: &[u8], - ) -> Result { - let signature = self.sign(platform_address, data)?; - Ok(AddressWitness::P2pkh { signature }) - } - - fn can_sign_with(&self, platform_address: &PlatformAddress) -> bool { - let PlatformAddress::P2pkh(hash) = platform_address else { - return false; - }; - let p2pkh = PlatformP2PKHAddress::new(*hash); - self.path_for(&p2pkh).is_ok() - } -} - -impl std::fmt::Debug for SeedBackedPlatformAddressSigner { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SeedBackedPlatformAddressSigner") - .field("network", &self.network) - .field("wallet_id", &hex::encode(self.wallet.wallet_id)) - .finish() - } -} +// NOTE: The `SeedBackedIdentitySigner` and `SeedBackedPlatformAddressSigner` +// impls were removed alongside the deleted +// `platform_wallet_register_identity_from_addresses` FFI. They were the +// only seed-driven signing path in this crate, and that path is now +// served by external `SignerHandle`s (see +// `rs-platform-wallet-ffi/src/identity_registration_with_signer.rs`). +// If a future flow needs in-memory seed signing, prefer wiring it +// through the `Signer` trait at the call site rather than reviving +// these wrappers — the seed should not cross the FFI boundary just so +// Rust can finish an operation (see `swift-sdk/CLAUDE.md`). diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 4eeebaa22da..5f2ee2fe1d0 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -19,11 +19,22 @@ dash-sdk = { path = "../rs-sdk", features = [ drive-proof-verifier = { path = "../rs-drive-proof-verifier" } rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider", features = [ "dpns-contract", + "dashpay-contract", + "withdrawals-contract", + "wallet-utils-contract", + "token-history-contract", + "keywords-contract", ] } simple-signer = { path = "../simple-signer" } async-trait = { version = "0.1.83" } dash-async = { path = "../rs-dash-async" } +# Used by `dash_sdk_sign_with_mnemonic_and_path` to derive an +# ECDSA key from `(mnemonic, path)` for one-shot signing without +# any FFI key export. `Mnemonic`, `bip32::*`, `Network` come from +# this crate. +key-wallet = { workspace = true } + # FFI and serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/packages/rs-sdk-ffi/src/signer.rs b/packages/rs-sdk-ffi/src/signer.rs index d1c2f74b8ac..eb074a0a7b1 100644 --- a/packages/rs-sdk-ffi/src/signer.rs +++ b/packages/rs-sdk-ffi/src/signer.rs @@ -41,6 +41,8 @@ use crate::types::SignerHandle; use async_trait::async_trait; +use dash_sdk::dpp::address_funds::{AddressWitness, PlatformAddress}; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::signer::Signer; use dash_sdk::dpp::platform_value::BinaryData; use dash_sdk::dpp::prelude::{IdentityPublicKey, ProtocolError}; @@ -52,6 +54,24 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::oneshot; +/// `key_type` discriminant byte used by the FFI signer trampoline to +/// indicate that the supplied "pubkey bytes" are actually the 20-byte +/// hash of a [`PlatformAddress`] (P2PKH). +/// +/// This sits outside the standard `dpp::identity::KeyType` enum range +/// (which only uses 0–4: `ECDSA_SECP256K1`, `BLS12_381`, `ECDSA_HASH160`, +/// `BIP13_SCRIPT_HASH`, `EDDSA_25519_HASH160`). Picking 0xFF guarantees +/// no collision with any future `KeyType` addition while still fitting +/// the existing `u8` slot in `SignAsyncCallback` / `CanSignCallback`, +/// so the same C vtable can serve both `Signer` and +/// `Signer` impls without an ABI change. +/// +/// The Swift side dispatches on this tag in the trampoline: +/// `key_type < 5` → look up `PersistentPublicKey` by raw pubkey bytes; +/// `key_type == 0xFF` → look up `PersistentPlatformAddress` by the +/// 20-byte address hash. See `KeychainSigner.swift`. +pub const SIGNER_KEY_TYPE_PLATFORM_ADDRESS_HASH: u8 = 0xFF; + /// Upper bound on how long the Rust side will wait for an iOS / FFI signer /// to invoke its completion callback after `sign_async` is called. /// @@ -96,15 +116,38 @@ pub struct SignerVTable { /// expected, for things like biometric-gated HSM signing) for the completion /// to run on a different thread than `SignAsyncCallback` itself. /// +/// # Wire shape +/// +/// `pubkey_bytes` / `pubkey_len` carry the **raw** public-key bytes as they +/// appear in `IdentityPublicKey::data()` (e.g. 33-byte compressed secp256k1 +/// for `ECDSA_SECP256K1`, 20-byte hash for `ECDSA_HASH160`, 48 bytes for +/// `BLS12_381`, etc.). `key_type` is the `dpp::identity::KeyType` +/// discriminant byte (see [`KeyType`] in rs-dpp): +/// +/// | byte | KeyType | +/// |------|-------------------------| +/// | 0 | `ECDSA_SECP256K1` | +/// | 1 | `BLS12_381` | +/// | 2 | `ECDSA_HASH160` | +/// | 3 | `BIP13_SCRIPT_HASH` | +/// | 4 | `EDDSA_25519_HASH160` | +/// +/// The two together are sufficient for an FFI signer to look up the +/// matching private-key handle in its own store (e.g. iOS Keychain keyed +/// on `PersistentPublicKey.publicKeyData`). This replaces the old +/// bincode-encoded `IdentityPublicKey` blob, which forced every iOS +/// signer to depend on the rs-dpp bincode schema. +/// /// # Safety -/// - `key_bytes` / `data` are only valid for the duration of this call. +/// - `pubkey_bytes` / `data` are only valid for the duration of this call. /// If the implementation needs them after returning, it must copy them. /// - `completion_ctx` is opaque; it must be passed verbatim to `completion`. /// - `completion` must be called exactly once. pub type SignAsyncCallback = unsafe extern "C" fn( signer: *const c_void, - key_bytes: *const u8, - key_len: usize, + pubkey_bytes: *const u8, + pubkey_len: usize, + key_type: u8, data: *const u8, data_len: usize, completion_ctx: *mut c_void, @@ -136,8 +179,16 @@ pub type SignCompletionCallback = unsafe extern "C" fn( ); /// Function pointer type for the synchronous `can_sign_with` callback. -pub type CanSignCallback = - unsafe extern "C" fn(signer: *const c_void, key_bytes: *const u8, key_len: usize) -> bool; +/// +/// Mirrors [`SignAsyncCallback`]'s key encoding: raw pubkey bytes plus +/// the `KeyType` discriminant byte, NOT a bincode-encoded +/// `IdentityPublicKey`. +pub type CanSignCallback = unsafe extern "C" fn( + signer: *const c_void, + pubkey_bytes: *const u8, + pubkey_len: usize, + key_type: u8, +) -> bool; /// Optional destructor callback (may be NULL from C). pub type DestroyCallback = Option; @@ -279,10 +330,12 @@ impl Signer for VTableSigner { Inner::Callback { signer_ptr, vtable, .. } => { - // Serialize the public key for the C side. - let key_bytes = - bincode::encode_to_vec(identity_public_key, bincode::config::standard()) - .map_err(|e| ProtocolError::EncodingError(e.to_string()))?; + // Pass the raw public-key bytes + KeyType discriminant + // across the FFI. The iOS side looks up the matching + // private key from its own store (Keychain) — it does + // not need the full `IdentityPublicKey` shape. + let pubkey = identity_public_key.data().as_slice(); + let key_type_byte = identity_public_key.key_type() as u8; // oneshot + leaked `CompletionSlot` — the slot's `AtomicPtr` // makes duplicate C completions a no-op (see `CompletionSlot`). @@ -299,8 +352,9 @@ impl Signer for VTableSigner { unsafe { ((*(*vtable)).sign_async)( *signer_ptr as *const c_void, - key_bytes.as_ptr(), - key_bytes.len(), + pubkey.as_ptr(), + pubkey.len(), + key_type_byte, data.as_ptr(), data.len(), completion_ctx, @@ -365,20 +419,202 @@ impl Signer for VTableSigner { Inner::Callback { signer_ptr, vtable, .. } => { + // Mirror the `sign_async` encoding: raw pubkey bytes + + // KeyType byte. iOS does its own SwiftData lookup. + let pubkey = identity_public_key.data().as_slice(); + let key_type_byte = identity_public_key.key_type() as u8; // SAFETY: vtable is non-null for the Callback variant. unsafe { - match bincode::encode_to_vec(identity_public_key, bincode::config::standard()) { - Ok(key_bytes) => ((*(*vtable)).can_sign_with)( - *signer_ptr as *const c_void, - key_bytes.as_ptr(), - key_bytes.len(), - ), - Err(_) => false, - } + ((*(*vtable)).can_sign_with)( + *signer_ptr as *const c_void, + pubkey.as_ptr(), + pubkey.len(), + key_type_byte, + ) + } + } + } + } +} + +// --------------------------------------------------------------------------- +// `Signer` impl on the same `VTableSigner` +// --------------------------------------------------------------------------- +// +// `register_from_addresses` (the address-funded identity-creation path) +// needs to sign two distinct things: +// +// 1. The new identity's state-transition keys — handled by the +// `Signer` impl above. +// 2. Each input platform address's funding contribution — handled by +// this `Signer` impl. +// +// The `Signer` trait in `rs-dpp` is generic over `K`, so the same +// `VTableSigner` struct can satisfy both bounds — the FFI vtable +// chooses what to do based on the `key_type` discriminant byte: +// the standard `KeyType` values (0–4) for identity-key signing, and +// [`SIGNER_KEY_TYPE_PLATFORM_ADDRESS_HASH`] (0xFF) for address signing. +// The byte payload across the FFI is the 20-byte address hash, so the +// Swift side can look up the matching SwiftData +// `PersistentPlatformAddress` row and resolve a Keychain-stored +// private key without ever crossing the seed back to Rust. +// +// The `Inner::Native` variant intentionally cannot satisfy +// `Signer` — its inner `Arc>` +// only signs identity keys. Callers that need address signing must use +// the `Callback` variant (the iOS production path) or supply a separate +// signer. + +#[async_trait] +impl Signer for VTableSigner { + async fn sign( + &self, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Result { + match &self.inner { + Inner::Native(_) => Err(ProtocolError::Generic( + "Native VTableSigner variant does not support Signer; \ + use a callback-based VTableSigner for platform-address signing" + .to_string(), + )), + Inner::Callback { + signer_ptr, vtable, .. + } => { + // Extract the 20-byte address hash and ship it across + // the FFI under the `0xFF` discriminant. + // + // P2PKH and P2SH share the same wire shape — the hash + // alone is the natural lookup key on the iOS side. + // Future watch-only / hardware paths can use the same + // tag to fan out further. + let hash: &[u8; 20] = match platform_address { + PlatformAddress::P2pkh(h) => h, + PlatformAddress::P2sh(h) => h, + }; + + let (tx, rx) = oneshot::channel::(); + let tx_ptr = Box::into_raw(Box::new(tx)); + let slot = Box::new(CompletionSlot { + sender: AtomicPtr::new(tx_ptr), + }); + let completion_ctx = Box::into_raw(slot) as *mut c_void; + + // SAFETY: vtable is non-null for the Callback variant + // by construction; sign_async is required to invoke + // `completion` exactly once. + unsafe { + ((*(*vtable)).sign_async)( + *signer_ptr as *const c_void, + hash.as_ptr(), + hash.len(), + SIGNER_KEY_TYPE_PLATFORM_ADDRESS_HASH, + data.as_ptr(), + data.len(), + completion_ctx, + dash_sdk_sign_async_completion, + ); + } + + match tokio::time::timeout(SIGN_ASYNC_COMPLETION_TIMEOUT, rx).await { + Ok(Ok(Ok(sig))) => Ok(BinaryData::from(sig)), + Ok(Ok(Err(e))) => Err(e), + Ok(Err(_recv_err)) => Err(ProtocolError::Generic( + "Signer completion channel dropped without a result; \ + the FFI signer did not call its completion callback" + .to_string(), + )), + Err(_elapsed) => Err(ProtocolError::Generic(format!( + "Signer completion callback not invoked within {:?}; \ + the FFI signer is unresponsive", + SIGN_ASYNC_COMPLETION_TIMEOUT + ))), } } } } + + async fn sign_create_witness( + &self, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Result { + // P2SH addresses are not yet supported across the FFI signer + // path — the Swift `KeychainSigner` only stores P2PKH key + // material. Reject P2SH explicitly so a future caller that + // unintentionally feeds a P2SH input gets a clear error + // rather than a structurally invalid `P2pkh` witness. + match platform_address { + PlatformAddress::P2pkh(_) => { + let signature = self.sign(platform_address, data).await?; + Ok(AddressWitness::P2pkh { signature }) + } + PlatformAddress::P2sh(_) => Err(ProtocolError::Generic( + "FFI signer does not yet support P2SH platform-address witnesses".to_string(), + )), + } + } + + fn can_sign_with(&self, platform_address: &PlatformAddress) -> bool { + match &self.inner { + Inner::Native(_) => false, + Inner::Callback { + signer_ptr, vtable, .. + } => { + let hash: &[u8; 20] = match platform_address { + PlatformAddress::P2pkh(h) => h, + PlatformAddress::P2sh(h) => h, + }; + // SAFETY: vtable is non-null for the Callback variant. + unsafe { + ((*(*vtable)).can_sign_with)( + *signer_ptr as *const c_void, + hash.as_ptr(), + hash.len(), + SIGNER_KEY_TYPE_PLATFORM_ADDRESS_HASH, + ) + } + } + } + } +} + +// --------------------------------------------------------------------------- +// VTableSignerRef parallel for `Signer` +// --------------------------------------------------------------------------- +// +// The `register_from_addresses` SDK entry point takes its address +// signer by `&AS where AS: Signer + Send + Sync`. +// We expose the same `VTableSignerRef` wrapper used for the +// identity signer so callers can hand both views over a single +// underlying `VTableSigner`. + +#[async_trait] +impl<'a> Signer for VTableSignerRef<'a> { + async fn sign( + &self, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Result { + >::sign(self.0, platform_address, data).await + } + + async fn sign_create_witness( + &self, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Result { + >::sign_create_witness( + self.0, + platform_address, + data, + ) + .await + } + + fn can_sign_with(&self, platform_address: &PlatformAddress) -> bool { + >::can_sign_with(self.0, platform_address) + } } /// Non-owning reference wrapper around a `VTableSigner` that itself @@ -483,10 +719,12 @@ pub unsafe extern "C" fn dash_sdk_sign_async_completion( /// - `destroy_callback`: optional destructor for the `signer` state. Pass NULL /// if there is nothing to clean up. /// -/// Note: there is intentionally no `signer_ptr` parameter here because the -/// pre-async iOS path never used it — the signing state was captured by the -/// C function pointers themselves. If you need a signer context pointer, -/// construct a `VTableSigner` from Rust using `VTableSigner::from_callback`. +/// Note: there is intentionally no `signer_ptr` parameter here — the +/// signing state is expected to be captured by the C function pointers +/// themselves (e.g. via global state). If you need to thread an opaque +/// context pointer through to your callbacks (the common iOS pattern, where +/// the context is `Unmanaged.passRetained(swiftSelf)`), use +/// [`dash_sdk_signer_create_with_ctx`] instead. /// /// # Safety /// - Callback function pointers must be valid and follow the required ABI @@ -498,6 +736,48 @@ pub unsafe extern "C" fn dash_sdk_signer_create( sign_async_callback: SignAsyncCallback, can_sign_callback: CanSignCallback, destroy_callback: DestroyCallback, +) -> *mut SignerHandle { + dash_sdk_signer_create_with_ctx( + std::ptr::null_mut(), + sign_async_callback, + can_sign_callback, + destroy_callback, + ) +} + +/// Create a new signer with async callbacks plus an opaque context pointer. +/// +/// `ctx` is forwarded verbatim as the first argument (`signer: *const c_void`) +/// of every `sign_async` / `can_sign_with` invocation, and as the lone +/// argument of the optional `destroy_callback`. iOS / Swift uses this to +/// pass an `Unmanaged.passRetained(self).toOpaque()` token so the +/// trampolines can re-acquire the owning Swift instance: +/// +/// ```text +/// // Swift, sketched: +/// let ctx = Unmanaged.passRetained(self).toOpaque() +/// let handle = dash_sdk_signer_create_with_ctx(ctx, +/// { signerPtr, ... in +/// let me = Unmanaged.fromOpaque(signerPtr!).takeUnretainedValue() +/// ... +/// }, +/// ..., destroyCb) +/// // destroyCb releases the +1 from passRetained. +/// ``` +/// +/// # Safety +/// - `ctx` may be null. If non-null it must remain valid for the life of +/// the signer and must outlive every callback the SDK might fire. +/// - Callback function pointers must be valid and follow the required ABI. +/// - The returned `SignerHandle` must be destroyed with +/// `dash_sdk_signer_destroy` to avoid leaks. The destructor is invoked +/// exactly once with `ctx`. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_signer_create_with_ctx( + ctx: *mut c_void, + sign_async_callback: SignAsyncCallback, + can_sign_callback: CanSignCallback, + destroy_callback: DestroyCallback, ) -> *mut SignerHandle { // Create a vtable on the heap so it persists for the life of the signer. let vtable = Box::new(SignerVTable { @@ -509,8 +789,9 @@ pub unsafe extern "C" fn dash_sdk_signer_create( let vtable_ptr = Box::into_raw(vtable); // SAFETY: vtable_ptr was just produced by Box::into_raw, so it is valid - // and we own it (owns_vtable = true). - let vtable_signer = VTableSigner::from_callback(std::ptr::null_mut(), vtable_ptr, true); + // and we own it (owns_vtable = true). `ctx` is treated as opaque — the + // vtable's `destroy` is responsible for cleaning it up. + let vtable_signer = VTableSigner::from_callback(ctx, vtable_ptr, true); Box::into_raw(Box::new(vtable_signer)) as *mut SignerHandle } @@ -579,8 +860,9 @@ mod tests { /// 64-byte signature. This simulates the simplest possible iOS signer. unsafe extern "C" fn test_sign_async_sync( _signer: *const c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut c_void, @@ -595,8 +877,9 @@ mod tests { /// Test sign callback that reports an error via the completion callback. unsafe extern "C" fn test_sign_async_error( _signer: *const c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut c_void, @@ -610,8 +893,9 @@ mod tests { /// completion — exercising the thread-safety of `oneshot::Sender`. unsafe extern "C" fn test_sign_async_threaded( _signer: *const c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut c_void, @@ -638,8 +922,9 @@ mod tests { /// Test can-sign callback. unsafe extern "C" fn test_can_sign( _signer: *const c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } @@ -729,8 +1014,9 @@ mod tests { /// single-shot guard; second and third calls must be no-ops. unsafe extern "C" fn test_sign_async_double_complete( _signer: *const c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut c_void, @@ -762,4 +1048,170 @@ mod tests { "first completion's signature must win; duplicate overwrite must be ignored" ); } + + // ------------------------------------------------------------------ + // Signer dispatch — verifies the trampoline sees + // `key_type == 0xFF` and the 20-byte address hash for both P2PKH + // and P2SH variants. + // + // Each dispatch test owns its own per-thread capture slot to keep + // tokio's parallel test runner from racing on a shared global — + // this also exercises the `passRetained`-style capture pattern the + // production iOS path uses (the `signer` pointer is the test's + // own `Box::into_raw`-ed slot). + // ------------------------------------------------------------------ + + /// Per-test capture slot for the trampoline. The dispatch tests + /// each allocate one, hand the raw pointer through the + /// `signer_ptr` slot of `VTableSigner::from_callback`, and read + /// the captured tuple back after the sign call returns. + struct DispatchCapture { + observed: std::sync::Mutex)>>, + } + + unsafe extern "C" fn capture_sign_async( + signer: *const c_void, + pubkey_bytes: *const u8, + pubkey_len: usize, + key_type: u8, + _data: *const u8, + _data_len: usize, + completion_ctx: *mut c_void, + completion: SignCompletionCallback, + ) { + let cap = &*(signer as *const DispatchCapture); + let bytes = if pubkey_bytes.is_null() { + Vec::new() + } else { + std::slice::from_raw_parts(pubkey_bytes, pubkey_len).to_vec() + }; + *cap.observed.lock().unwrap() = Some((key_type, bytes)); + let sig = [0x77u8; 64]; + completion(completion_ctx, sig.as_ptr(), sig.len(), std::ptr::null()); + } + + unsafe extern "C" fn capture_can_sign( + signer: *const c_void, + pubkey_bytes: *const u8, + pubkey_len: usize, + key_type: u8, + ) -> bool { + let cap = &*(signer as *const DispatchCapture); + let bytes = if pubkey_bytes.is_null() { + Vec::new() + } else { + std::slice::from_raw_parts(pubkey_bytes, pubkey_len).to_vec() + }; + *cap.observed.lock().unwrap() = Some((key_type, bytes)); + true + } + + /// Vtable destructor for the dispatch tests — reclaims the + /// `Box` we leaked via `Box::into_raw`. + unsafe extern "C" fn capture_destroy(signer: *mut c_void) { + if !signer.is_null() { + let _ = Box::from_raw(signer as *mut DispatchCapture); + } + } + + /// Build a `(VTableSigner, &DispatchCapture)` pair for one test. + /// The capture slot is owned by the signer's vtable destructor — + /// the returned reference is valid for as long as the signer is + /// alive (the test holds the signer on its stack). + fn make_dispatch_signer() -> (VTableSigner, *const DispatchCapture) { + let cap = Box::new(DispatchCapture { + observed: std::sync::Mutex::new(None), + }); + let cap_ptr = Box::into_raw(cap); + + let vtable = Box::new(SignerVTable { + sign_async: capture_sign_async, + can_sign_with: capture_can_sign, + destroy: capture_destroy, + }); + let vtable_ptr = Box::into_raw(vtable); + + // SAFETY: `cap_ptr` and `vtable_ptr` are both freshly + // `Box::into_raw`-ed; the destructor reclaims `cap_ptr` and + // `owns_vtable = true` reclaims `vtable_ptr` on drop. + let signer = + unsafe { VTableSigner::from_callback(cap_ptr as *mut c_void, vtable_ptr, true) }; + (signer, cap_ptr as *const DispatchCapture) + } + + #[tokio::test] + async fn platform_address_signer_dispatches_p2pkh_with_0xff_tag() { + let (signer, cap) = make_dispatch_signer(); + let hash = [0xAAu8; 20]; + let address = PlatformAddress::P2pkh(hash); + + let sig = >::sign(&signer, &address, &[1, 2, 3]) + .await + .expect("sign should succeed"); + assert_eq!(sig.len(), 64); + + let observed = unsafe { (*cap).observed.lock().unwrap().clone() }; + let (key_type, bytes) = observed.expect("trampoline must have been invoked exactly once"); + assert_eq!( + key_type, SIGNER_KEY_TYPE_PLATFORM_ADDRESS_HASH, + "P2PKH dispatch must use the 0xFF discriminant byte" + ); + assert_eq!(bytes.as_slice(), &hash, "20-byte hash must round-trip"); + } + + #[tokio::test] + async fn platform_address_signer_dispatches_p2sh_with_0xff_tag() { + let (signer, cap) = make_dispatch_signer(); + let hash = [0xBBu8; 20]; + let address = PlatformAddress::P2sh(hash); + + let _ = >::sign(&signer, &address, &[4, 5, 6]) + .await + .expect("sign should succeed"); + + let observed = unsafe { (*cap).observed.lock().unwrap().clone() }; + let (key_type, bytes) = observed.expect("trampoline must have been invoked"); + assert_eq!(key_type, SIGNER_KEY_TYPE_PLATFORM_ADDRESS_HASH); + assert_eq!(bytes.as_slice(), &hash); + } + + #[tokio::test] + async fn platform_address_signer_can_sign_with_dispatches_0xff() { + let (signer, cap) = make_dispatch_signer(); + let hash = [0xCCu8; 20]; + let address = PlatformAddress::P2pkh(hash); + + assert!(>::can_sign_with( + &signer, &address + )); + + let observed = unsafe { (*cap).observed.lock().unwrap().clone() }; + let (key_type, bytes) = observed.expect("can_sign_with must invoke the trampoline"); + assert_eq!(key_type, SIGNER_KEY_TYPE_PLATFORM_ADDRESS_HASH); + assert_eq!(bytes.as_slice(), &hash); + } + + #[tokio::test] + async fn identity_signer_does_not_use_0xff_tag() { + // Sanity-check the dispatch boundary: identity-key signing + // must continue to use the standard KeyType discriminant. + let (signer, cap) = make_dispatch_signer(); + let key = make_dummy_key(); + + let _ = signer + .sign(&key, &[7, 8, 9]) + .await + .expect("sign should succeed"); + + let observed = unsafe { (*cap).observed.lock().unwrap().clone() }; + let (key_type, _) = observed.expect("trampoline must have been invoked"); + assert_ne!( + key_type, SIGNER_KEY_TYPE_PLATFORM_ADDRESS_HASH, + "identity-key signing must NOT use the platform-address tag" + ); + assert!( + key_type < 5, + "identity-key signing must use a real KeyType discriminant (0–4); got {key_type}" + ); + } } diff --git a/packages/rs-sdk-ffi/src/signer_simple.rs b/packages/rs-sdk-ffi/src/signer_simple.rs index 04e818bcd49..4592da879e1 100644 --- a/packages/rs-sdk-ffi/src/signer_simple.rs +++ b/packages/rs-sdk-ffi/src/signer_simple.rs @@ -13,6 +13,44 @@ use zeroize::Zeroizing; // `Runtime::new().block_on(...)`. use dash_async::block_on; +/// Parse a BIP-39 mnemonic phrase against every supported wordlist +/// in turn, returning the first language that yields a valid mnemonic. +/// +/// `key_wallet::Mnemonic` only exposes language-tagged constructors +/// (`Mnemonic::from_phrase(phrase, lang)`); the upstream `bip39` crate +/// has a `Mnemonic::parse(phrase)` that auto-detects, but it is not +/// re-exported through the `key_wallet` wrapper. Until that helper is +/// surfaced upstream, callers who accept user-supplied mnemonics need +/// to walk the language list themselves so a French / Japanese / etc. +/// phrase is not rejected as "invalid English". +/// +/// BIP-39 wordlists are designed to be mutually exclusive within a +/// single phrase, so the first match is unambiguous. +pub(crate) fn parse_mnemonic_any_language( + phrase: &str, +) -> Result { + use key_wallet::mnemonic::{Language, Mnemonic}; + + const LANGUAGES: [Language; 10] = [ + Language::English, + Language::Spanish, + Language::French, + Language::Italian, + Language::Japanese, + Language::Korean, + Language::ChineseSimplified, + Language::ChineseTraditional, + Language::Czech, + Language::Portuguese, + ]; + for lang in LANGUAGES { + if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { + return Ok(m); + } + } + Err("phrase does not match any supported BIP-39 wordlist") +} + /// Create a signer from a private key. /// /// Internally wraps a `SingleKeySigner` as a native (non-callback) FFI @@ -178,3 +216,265 @@ pub unsafe extern "C" fn dash_sdk_signature_free(signature: *mut DashSDKSignatur } } } + +// --------------------------------------------------------------------------- +// One-shot derive-then-sign FFI for platform-address keys +// --------------------------------------------------------------------------- +// +// The KeychainSigner trampoline calls this on the `key_type == 0xFF` +// branch (platform-address signing). The derived key never crosses +// the FFI as bytes — Swift hands in the mnemonic + a derivation path, +// gets back just a signature. Both the seed and the derived key are +// held inside `Zeroizing` buffers and dropped at the end of the call. +// +// This is intentionally narrow: ECDSA secp256k1 only, single derivation +// path, no metadata. The Swift caller is responsible for pulling the +// path off `PersistentPlatformAddress.derivationPath` and the mnemonic +// off Keychain (`WalletStorage.retrieveMnemonic(for:)`) per signing +// call. + +/// Error categories returned via `out_error` from +/// [`dash_sdk_sign_with_mnemonic_and_path`]. Kept as bytes (rather +/// than a `c_char*` or `DashSDKError*`) so the Swift trampoline can +/// branch on a single value without parsing strings — the failure +/// modes here are few enough that a one-byte tag is more useful than +/// a freeform message. +pub const SIGN_WITH_MNEMONIC_OK: u8 = 0; +pub const SIGN_WITH_MNEMONIC_ERR_NULL_POINTER: u8 = 1; +pub const SIGN_WITH_MNEMONIC_ERR_INVALID_UTF8: u8 = 2; +pub const SIGN_WITH_MNEMONIC_ERR_INVALID_MNEMONIC: u8 = 3; +pub const SIGN_WITH_MNEMONIC_ERR_INVALID_PATH: u8 = 4; +pub const SIGN_WITH_MNEMONIC_ERR_DERIVATION: u8 = 5; +pub const SIGN_WITH_MNEMONIC_ERR_SIGN: u8 = 6; +pub const SIGN_WITH_MNEMONIC_ERR_BUFFER_TOO_SMALL: u8 = 7; +pub const SIGN_WITH_MNEMONIC_ERR_UNSUPPORTED_KEY_TYPE: u8 = 8; + +/// Derive an ECDSA secp256k1 private key from +/// `(mnemonic, passphrase, derivation_path)`, sign `data` with it, +/// write the signature to `out_signature`. The derived key is held +/// only for the duration of this call; both the seed and the key +/// buffers are zeroed before return via [`zeroize::Zeroizing`]. +/// +/// `out_signature` must point at a writable buffer of +/// `out_signature_capacity` bytes. On success `*out_signature_len` is +/// set to the actual signature length (65 bytes for compact recoverable +/// ECDSA signatures produced by `dashcore::signer::sign`). +/// +/// `key_type` is the standard `KeyType` discriminant byte. For +/// platform-address signing it's always `KeyType::ECDSA_SECP256K1 = 0`; +/// the parameter exists for parity with the trampoline shape and to +/// fail loudly with [`SIGN_WITH_MNEMONIC_ERR_UNSUPPORTED_KEY_TYPE`] +/// if a non-ECDSA key_type is ever requested through this path. Other +/// key types can be added when the `Signer` protocol +/// grows beyond identity keys. +/// +/// `network` selects the network passed to +/// `ExtendedPrivKey::new_master`. The derived secret bytes are +/// network-independent (BIP-32 derivation only uses `network` as a +/// tag on the `ExtendedPrivKey`), but we thread it through so the +/// type matches every other key-derivation entry point in this crate. +/// Mirrors [`DashSDKNetwork`] used elsewhere in `signer_simple.rs`. +/// +/// On any failure the function writes a non-zero tag into `*out_error`, +/// zeroes the first `out_signature_capacity` bytes of `out_signature`, +/// sets `*out_signature_len = 0`, and returns a non-zero `i32`. +/// +/// Return value: +/// - `0` → success. +/// - `-1` → error (see `*out_error` for the category). +/// +/// # Safety +/// - `mnemonic_cstr` and `derivation_path_cstr` must be valid, +/// null-terminated UTF-8 C strings for the duration of the call. +/// - `passphrase_cstr` may be null (empty passphrase). +/// - `data` must point at `data_len` readable bytes (may be zero only +/// if `data_len == 0`). +/// - `out_signature` must point at `out_signature_capacity` writable +/// bytes; `out_signature_len` and `out_error` must be writable. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn dash_sdk_sign_with_mnemonic_and_path( + mnemonic_cstr: *const std::os::raw::c_char, + passphrase_cstr: *const std::os::raw::c_char, + derivation_path_cstr: *const std::os::raw::c_char, + data: *const u8, + data_len: usize, + key_type: u8, + network: DashSDKNetwork, + out_signature: *mut u8, + out_signature_capacity: usize, + out_signature_len: *mut usize, + out_error: *mut u8, +) -> i32 { + use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::identity::KeyType; + use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; + use std::ffi::CStr; + use std::str::FromStr; + + // Internal helper that scrubs `out_signature` + `*out_signature_len` + // and returns the failure tag. Branchless to the eye, single exit + // point for every error path. + let fail = |tag: u8| -> i32 { + if !out_error.is_null() { + *out_error = tag; + } + if !out_signature_len.is_null() { + *out_signature_len = 0; + } + if !out_signature.is_null() && out_signature_capacity > 0 { + std::ptr::write_bytes(out_signature, 0, out_signature_capacity); + } + -1 + }; + + // ---- Argument validation ------------------------------------------------- + if mnemonic_cstr.is_null() + || derivation_path_cstr.is_null() + || out_signature.is_null() + || out_signature_len.is_null() + || (data.is_null() && data_len > 0) + { + return fail(SIGN_WITH_MNEMONIC_ERR_NULL_POINTER); + } + + // ECDSA-only entry point. Any other key_type is a contract + // violation by the caller — surface it instead of pretending to + // succeed. + if key_type != KeyType::ECDSA_SECP256K1 as u8 { + return fail(SIGN_WITH_MNEMONIC_ERR_UNSUPPORTED_KEY_TYPE); + } + + // ---- UTF-8 inputs -------------------------------------------------------- + let mnemonic_str = match CStr::from_ptr(mnemonic_cstr).to_str() { + Ok(s) => s, + Err(_) => return fail(SIGN_WITH_MNEMONIC_ERR_INVALID_UTF8), + }; + let passphrase_str: &str = if passphrase_cstr.is_null() { + "" + } else { + match CStr::from_ptr(passphrase_cstr).to_str() { + Ok(s) => s, + Err(_) => return fail(SIGN_WITH_MNEMONIC_ERR_INVALID_UTF8), + } + }; + let path_str = match CStr::from_ptr(derivation_path_cstr).to_str() { + Ok(s) => s, + Err(_) => return fail(SIGN_WITH_MNEMONIC_ERR_INVALID_UTF8), + }; + + // ---- Mnemonic + seed ----------------------------------------------------- + // Walk every supported BIP-39 wordlist (English, Spanish, French, + // Italian, Japanese, Korean, both Chinese, Czech, Portuguese) so a + // non-English mnemonic is not falsely rejected. The wordlists are + // disjoint per the BIP-39 spec, so the first match is the right + // language. + let mnemonic = match parse_mnemonic_any_language(mnemonic_str) { + Ok(m) => m, + Err(_) => return fail(SIGN_WITH_MNEMONIC_ERR_INVALID_MNEMONIC), + }; + // `to_seed` returns `[u8; 64]` by value; wrap immediately so the + // 64 bytes get zeroed when this function returns (success or fail). + let seed: zeroize::Zeroizing<[u8; 64]> = + zeroize::Zeroizing::new(mnemonic.to_seed(passphrase_str)); + + // ---- Derivation path ----------------------------------------------------- + let path = match DerivationPath::from_str(path_str) { + Ok(p) => p, + Err(_) => return fail(SIGN_WITH_MNEMONIC_ERR_INVALID_PATH), + }; + + // Map the C-ABI network enum to dashcore::Network. Mirrors the + // mapping used in `dash_sdk_signer_create_from_private_key` above. + let dashcore_network = match network { + DashSDKNetwork::SDKMainnet => dash_sdk::dpp::dashcore::Network::Mainnet, + DashSDKNetwork::SDKTestnet => dash_sdk::dpp::dashcore::Network::Testnet, + DashSDKNetwork::SDKRegtest => dash_sdk::dpp::dashcore::Network::Regtest, + DashSDKNetwork::SDKDevnet => dash_sdk::dpp::dashcore::Network::Devnet, + DashSDKNetwork::SDKLocal => dash_sdk::dpp::dashcore::Network::Regtest, + }; + + // ---- Derive ECDSA private key at path ----------------------------------- + let master = match ExtendedPrivKey::new_master(dashcore_network, seed.as_ref()) { + Ok(m) => m, + Err(_) => return fail(SIGN_WITH_MNEMONIC_ERR_DERIVATION), + }; + let secp = Secp256k1::new(); + let derived = match master.derive_priv(&secp, &path) { + Ok(d) => d, + Err(_) => return fail(SIGN_WITH_MNEMONIC_ERR_DERIVATION), + }; + // Pull the 32 secret bytes into a `Zeroizing` so they're scrubbed + // when this function returns (the `ExtendedPrivKey` itself doesn't + // zeroize — `derived` falls out of scope intact). + let secret_bytes: zeroize::Zeroizing<[u8; 32]> = + zeroize::Zeroizing::new(derived.private_key.secret_bytes()); + + // ---- Sign --------------------------------------------------------------- + // `dashcore::signer::sign` returns a 65-byte compact recoverable + // ECDSA signature — the same shape used by the existing + // `Signer` impls in `rs-platform-wallet`. + let data_slice: &[u8] = if data_len == 0 { + &[] + } else { + std::slice::from_raw_parts(data, data_len) + }; + let sig_array = match dash_sdk::dpp::dashcore::signer::sign(data_slice, secret_bytes.as_ref()) { + Ok(s) => s, + Err(_) => return fail(SIGN_WITH_MNEMONIC_ERR_SIGN), + }; + + if sig_array.len() > out_signature_capacity { + return fail(SIGN_WITH_MNEMONIC_ERR_BUFFER_TOO_SMALL); + } + + // ---- Copy signature out -------------------------------------------------- + std::ptr::copy_nonoverlapping(sig_array.as_ptr(), out_signature, sig_array.len()); + *out_signature_len = sig_array.len(); + if !out_error.is_null() { + *out_error = SIGN_WITH_MNEMONIC_OK; + } + + // `seed` and `secret_bytes` are zeroed via `Zeroizing` on Drop here. + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// English BIP-39 test vector (all-zero entropy). + const ENGLISH_PHRASE: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + /// Japanese BIP-39 test vector (all-zero entropy). Uses the + /// ideographic-space-separated form per the Japanese wordlist + /// convention; the upstream `bip39` crate normalizes both forms. + const JAPANESE_PHRASE: &str = "あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あおぞら"; + + #[test] + fn parse_mnemonic_any_language_accepts_english() { + let m = parse_mnemonic_any_language(ENGLISH_PHRASE) + .expect("English phrase must parse via auto-detection"); + assert_eq!(m.word_count(), 12); + } + + #[test] + fn parse_mnemonic_any_language_accepts_japanese() { + // Distinct wordlist from English — exercises the + // "first-language-doesn't-match, fall through" branch of the + // detection loop. Ensures non-English phrases are not rejected + // as `ERR_INVALID_MNEMONIC`. + let m = parse_mnemonic_any_language(JAPANESE_PHRASE) + .expect("Japanese phrase must parse via auto-detection"); + assert_eq!(m.word_count(), 12); + } + + #[test] + fn parse_mnemonic_any_language_rejects_garbage() { + // No supported wordlist contains these tokens, so every + // attempt in the loop must fail and the helper must surface + // the catch-all error. + assert!(parse_mnemonic_any_language("not a real bip39 phrase at all here").is_err()); + } +} diff --git a/packages/rs-sdk-ffi/src/test_utils.rs b/packages/rs-sdk-ffi/src/test_utils.rs index 67ec952b729..bcd4444c5b7 100644 --- a/packages/rs-sdk-ffi/src/test_utils.rs +++ b/packages/rs-sdk-ffi/src/test_utils.rs @@ -51,8 +51,9 @@ pub mod test_utils { // enough to drive FFI entry points that only care that "a signer ran". pub unsafe extern "C" fn mock_sign_async_callback( _signer: *const c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut c_void, @@ -70,8 +71,9 @@ pub mod test_utils { // Mock can sign callback for vtable pub unsafe extern "C" fn mock_can_sign_callback( _signer: *const c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } diff --git a/packages/rs-sdk-ffi/src/token/claim.rs b/packages/rs-sdk-ffi/src/token/claim.rs index 0c7c3db616b..627d339e05f 100644 --- a/packages/rs-sdk-ffi/src/token/claim.rs +++ b/packages/rs-sdk-ffi/src/token/claim.rs @@ -235,8 +235,9 @@ mod tests { // Mock async sign callback for the completion-callback signer vtable. unsafe extern "C" fn mock_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut std::os::raw::c_void, @@ -255,8 +256,9 @@ mod tests { unsafe extern "C" fn mock_can_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } diff --git a/packages/rs-sdk-ffi/src/token/config_update.rs b/packages/rs-sdk-ffi/src/token/config_update.rs index bc6500f3b03..7c1fca2a734 100644 --- a/packages/rs-sdk-ffi/src/token/config_update.rs +++ b/packages/rs-sdk-ffi/src/token/config_update.rs @@ -281,8 +281,9 @@ mod tests { // Mock async sign callback for the completion-callback signer vtable. unsafe extern "C" fn mock_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut std::os::raw::c_void, @@ -301,8 +302,9 @@ mod tests { unsafe extern "C" fn mock_can_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } diff --git a/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs b/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs index 679f62dcb5f..24e42ddf5a2 100644 --- a/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs +++ b/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs @@ -227,8 +227,9 @@ mod tests { // Mock async sign callback for the completion-callback signer vtable. unsafe extern "C" fn mock_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut std::os::raw::c_void, @@ -247,8 +248,9 @@ mod tests { unsafe extern "C" fn mock_can_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } diff --git a/packages/rs-sdk-ffi/src/token/emergency_action.rs b/packages/rs-sdk-ffi/src/token/emergency_action.rs index bb312614edc..52dd6e308b6 100644 --- a/packages/rs-sdk-ffi/src/token/emergency_action.rs +++ b/packages/rs-sdk-ffi/src/token/emergency_action.rs @@ -242,8 +242,9 @@ mod tests { // Mock async sign callback for the completion-callback signer vtable. unsafe extern "C" fn mock_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut std::os::raw::c_void, @@ -262,8 +263,9 @@ mod tests { unsafe extern "C" fn mock_can_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } diff --git a/packages/rs-sdk-ffi/src/token/freeze.rs b/packages/rs-sdk-ffi/src/token/freeze.rs index 4ffb9cde828..d13bccf771b 100644 --- a/packages/rs-sdk-ffi/src/token/freeze.rs +++ b/packages/rs-sdk-ffi/src/token/freeze.rs @@ -244,8 +244,9 @@ mod tests { // Mock async sign callback for the completion-callback signer vtable. unsafe extern "C" fn mock_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut std::os::raw::c_void, @@ -264,8 +265,9 @@ mod tests { unsafe extern "C" fn mock_can_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } diff --git a/packages/rs-sdk-ffi/src/token/mint.rs b/packages/rs-sdk-ffi/src/token/mint.rs index 55c8aeb6f06..0eaaf989639 100644 --- a/packages/rs-sdk-ffi/src/token/mint.rs +++ b/packages/rs-sdk-ffi/src/token/mint.rs @@ -335,8 +335,9 @@ mod tests { // Mock async sign callback for the completion-callback signer vtable. unsafe extern "C" fn mock_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut std::os::raw::c_void, @@ -355,8 +356,9 @@ mod tests { unsafe extern "C" fn mock_can_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } diff --git a/packages/rs-sdk-ffi/src/token/purchase.rs b/packages/rs-sdk-ffi/src/token/purchase.rs index 0bbc8925927..7227fdb2b56 100644 --- a/packages/rs-sdk-ffi/src/token/purchase.rs +++ b/packages/rs-sdk-ffi/src/token/purchase.rs @@ -218,8 +218,9 @@ mod tests { // Mock async sign callback for the completion-callback signer vtable. unsafe extern "C" fn mock_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut std::os::raw::c_void, @@ -238,8 +239,9 @@ mod tests { unsafe extern "C" fn mock_can_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } diff --git a/packages/rs-sdk-ffi/src/token/set_price.rs b/packages/rs-sdk-ffi/src/token/set_price.rs index 38dca9218df..4f469973d0a 100644 --- a/packages/rs-sdk-ffi/src/token/set_price.rs +++ b/packages/rs-sdk-ffi/src/token/set_price.rs @@ -266,8 +266,9 @@ mod tests { // Mock async sign callback for the completion-callback signer vtable. unsafe extern "C" fn mock_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut std::os::raw::c_void, @@ -286,8 +287,9 @@ mod tests { unsafe extern "C" fn mock_can_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } diff --git a/packages/rs-sdk-ffi/src/token/transfer.rs b/packages/rs-sdk-ffi/src/token/transfer.rs index 18ded7517d4..da1b40b94f5 100644 --- a/packages/rs-sdk-ffi/src/token/transfer.rs +++ b/packages/rs-sdk-ffi/src/token/transfer.rs @@ -235,8 +235,9 @@ mod tests { // Mock async sign callback for the completion-callback signer vtable. unsafe extern "C" fn mock_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut std::os::raw::c_void, @@ -255,8 +256,9 @@ mod tests { unsafe extern "C" fn mock_can_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } diff --git a/packages/rs-sdk-ffi/src/token/unfreeze.rs b/packages/rs-sdk-ffi/src/token/unfreeze.rs index 5e987c94b95..f20dba4b560 100644 --- a/packages/rs-sdk-ffi/src/token/unfreeze.rs +++ b/packages/rs-sdk-ffi/src/token/unfreeze.rs @@ -226,8 +226,9 @@ mod tests { // Mock async sign callback for the completion-callback signer vtable. unsafe extern "C" fn mock_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, _data: *const u8, _data_len: usize, completion_ctx: *mut std::os::raw::c_void, @@ -246,8 +247,9 @@ mod tests { unsafe extern "C" fn mock_can_sign_callback( _signer: *const std::os::raw::c_void, - _key_bytes: *const u8, - _key_len: usize, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, ) -> bool { true } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift new file mode 100644 index 00000000000..70e5d9d4cac --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift @@ -0,0 +1,679 @@ +import DashSDKFFI +import Foundation +import SwiftData + +// MARK: - KeychainSigner + +/// Production `Signer` implementation backed by `PersistentPublicKey` rows +/// in SwiftData and the `KeychainManager` private-key store. +/// +/// # How a signing request flows +/// +/// 1. Rust calls the C-ABI `sign_async` trampoline with the **raw** +/// public-key bytes, the [`KeyType`] discriminant byte, and the data +/// to sign (see `SignAsyncCallback` in `rs-sdk-ffi/src/signer.rs`). +/// 2. The trampoline locates the matching `PersistentPublicKey` row by +/// `publicKeyData == receivedPubkeyBytes` on a private background +/// `ModelContext`. +/// 3. It reads the row's `privateKeyKeychainIdentifier`, fetches the +/// 32-byte ECDSA scalar from the iOS Keychain via +/// `KeychainManager.retrieveKeyData(identifier:)`. +/// 4. It hands those bytes to `dash_sdk_signer_create_from_private_key` +/// + `dash_sdk_signer_sign` to produce a signature, then immediately +/// destroys the per-call signer and zeroes the local key buffer. +/// 5. The signature is shipped back to Rust through the +/// `SignCompletionCallback`. +/// +/// # Why round-trip through `dash_sdk_signer_*` (v1) +/// +/// The `swift-sdk/CLAUDE.md` rule forbids *pipelines* +/// (mnemonic → seed → path → derive → sign) on the Swift side, **not** +/// "Swift briefly holds a key for a single sign call." Every signing path +/// — Rust-side, hardware-backed, anywhere — has to materialise the key +/// in process memory at some point. We keep the window narrow: +/// retrieve → sign → zero. v1 reuses the existing +/// `dash_sdk_signer_create_from_private_key` round-trip rather than +/// pulling in a Swift-native secp256k1 dependency just to start; v2 will +/// add `swift-secp256k1` so we can sign without ever building an FFI +/// signer per call. +/// +/// TODO(KeychainSigner v2): replace the +/// `dash_sdk_signer_create_from_private_key` + `dash_sdk_signer_sign` +/// + `dash_sdk_signer_destroy` round-trip with a native-Swift +/// `secp256k1` sign, removing one allocation + one `Vec` copy per +/// signature and shrinking the key-material window further. +/// +/// # Threading +/// +/// `SignAsyncCallback` may fire from any Tokio worker thread. The +/// trampolines run synchronously to completion (the async-callback +/// model is for slow biometric prompts; v1 round-trip is fast enough +/// to invoke the completion before returning). The private background +/// `ModelContext` is created in `init` and pinned to this instance — +/// SwiftData `ModelContext` is not `Sendable` but we serialise access +/// to it through a single internal queue. +/// +/// # Lifetime +/// +/// `init` allocates a `*mut SignerHandle` via +/// `dash_sdk_signer_create_with_ctx`. The Rust side captures the +/// vtable + the opaque `ctx` (which is +/// `Unmanaged.passRetained(self).toOpaque()` — a +1 retain on `self`). +/// `deinit` calls `dash_sdk_signer_destroy`, which fires our +/// destructor trampoline, which drops the +1 retain. The handle is +/// safe to pass to FFI calls for the entire lifetime of the +/// `KeychainSigner` instance. +public final class KeychainSigner: Signer, @unchecked Sendable { + // MARK: Public surface + + /// Owned for the lifetime of this object. Pass this to any + /// `_with_signer` FFI entry point. Freed in `deinit` via + /// `dash_sdk_signer_destroy`. + public var handle: OpaquePointer { + OpaquePointer(handlePtr) + } + + // MARK: Errors + + public enum Error: Swift.Error, LocalizedError { + case publicKeyNotFound + case privateKeyMissingFromKeychain(account: String) + case ffiSignerCreationFailed(message: String) + case ffiSignFailed(message: String) + case modelContainerUnavailable + /// `key_type == 0xFF` (platform-address) branch: no + /// `PersistentPlatformAddress` row matched the supplied + /// 20-byte address hash. Either the wallet hasn't synced + /// the address pool yet or the row was deleted. + case platformAddressNotFound(addressHashHex: String) + /// `key_type == 0xFF` branch: the matched + /// `PersistentPlatformAddress` row has no derivation path. + /// Indicates a corrupt persister write — every row should + /// carry a non-empty DIP-17 path. + case derivationPathMissing(addressHashHex: String) + /// `key_type == 0xFF` branch: the wallet-mnemonic Keychain + /// item is missing for the wallet that owns this address. + case mnemonicMissing(walletIdHex: String) + /// `dash_sdk_sign_with_mnemonic_and_path` returned a + /// non-zero error tag. The tag is the byte written to + /// `out_error` (see `SIGN_WITH_MNEMONIC_ERR_*` constants in + /// `signer_simple.rs`). + case signWithMnemonicFailed(tag: UInt8) + + public var errorDescription: String? { + switch self { + case .publicKeyNotFound: + return "No PersistentPublicKey row matches the supplied public-key bytes." + case .privateKeyMissingFromKeychain(let account): + return "Keychain has no entry for account '\(account)'." + case .ffiSignerCreationFailed(let message): + return "Failed to construct FFI signer: \(message)" + case .ffiSignFailed(let message): + return "FFI sign call failed: \(message)" + case .modelContainerUnavailable: + return "ModelContainer is no longer available." + case .platformAddressNotFound(let hashHex): + return "No PersistentPlatformAddress row matches address hash \(hashHex)." + case .derivationPathMissing(let hashHex): + return "PersistentPlatformAddress row for \(hashHex) has no derivation path." + case .mnemonicMissing(let walletIdHex): + return "No Keychain mnemonic stored for wallet \(walletIdHex)." + case .signWithMnemonicFailed(let tag): + return "dash_sdk_sign_with_mnemonic_and_path failed with error tag \(tag)." + } + } + } + + // MARK: Storage + + private let modelContainer: ModelContainer + private let keychain: KeychainManager + /// Network is only used for `dash_sdk_signer_create_from_private_key`, + /// which uses it for WIF / address derivation but not for signing + /// itself. Stored here so the trampoline doesn't have to plumb it. + private let network: DashSDKNetwork + /// Background `ModelContext` pinned to this signer. Created lazily + /// per-trampoline-call (see `lookupPrivateKey`) — SwiftData + /// `ModelContext` instances are cheap and let us stay off the + /// `@MainActor` even though the parent `ModelContainer` was + /// constructed there. + private let queue: DispatchQueue + + /// Raw pointer to the FFI signer handle. Boxed by Rust and freed + /// in `deinit`. + private var handlePtr: UnsafeMutablePointer! + + // MARK: Init + + /// - Parameters: + /// - modelContainer: the SwiftData container holding + /// `PersistentPublicKey` rows. We store a strong reference and + /// spawn ad-hoc background `ModelContext`s against it from + /// trampoline callbacks. + /// - network: forwarded to `dash_sdk_signer_create_from_private_key` + /// for WIF address derivation; does not affect signature output. + /// - keychain: defaults to `KeychainManager.shared`. + public init( + modelContainer: ModelContainer, + network: DashSDKNetwork = DashSDKNetwork(rawValue: 1), + keychain: KeychainManager = .shared + ) { + self.modelContainer = modelContainer + self.network = network + self.keychain = keychain + self.queue = DispatchQueue(label: "org.dashfoundation.swiftdashsdk.KeychainSigner") + + // Hand Rust an opaque `+1`-retained pointer to self. The + // matching release happens in `keychainSignerDestroyTrampoline` + // when the FFI handle is destroyed. Until then `self` cannot + // be deallocated even if Swift drops every other reference. + let ctx = Unmanaged.passRetained(self).toOpaque() + + let handlePtr = dash_sdk_signer_create_with_ctx( + ctx, + keychainSignerSignAsyncTrampoline, + keychainSignerCanSignTrampoline, + keychainSignerDestroyTrampoline + ) + precondition( + handlePtr != nil, + "dash_sdk_signer_create_with_ctx returned NULL" + ) + self.handlePtr = handlePtr + } + + deinit { + // `dash_sdk_signer_destroy` drops the box, which fires the + // vtable destructor trampoline. The trampoline performs the + // matching `Unmanaged.fromOpaque(...).release()`, balancing + // the `passRetained` from `init`. + if let handlePtr { + dash_sdk_signer_destroy(handlePtr) + } + } + + // MARK: Trampoline-callable internals + + // MARK: key_type dispatch + // + // The Rust signer FFI sends one of two payload shapes through the + // trampoline, distinguished by the `key_type` discriminant byte: + // + // key_type 0…4 → `Signer` flow. + // `pubkey_bytes` are the raw IdentityPublicKey + // `data()` bytes (33-byte compressed secp256k1 + // for ECDSA, 20-byte hash for ECDSA_HASH160, + // etc.). Look up `PersistentPublicKey` → + // `privateKeyKeychainIdentifier` → Keychain + // bytes. The identity private key IS persisted + // in the Keychain (primary storage). + // + // key_type 0xFF → `Signer` flow (the + // `SIGNER_KEY_TYPE_PLATFORM_ADDRESS_HASH` + // constant in `rs-sdk-ffi/src/signer.rs`). + // `pubkey_bytes` are the 20-byte address hash + // of a `PlatformAddress::P2pkh` (or P2SH). + // Platform-address private keys are NOT + // persisted — they are derivation outputs of + // `(mnemonic, derivation_path)` and exist only + // for the duration of one signing call. + // Resolution: SwiftData + // `PersistentPlatformAddress.derivationPath` + // + Keychain `WalletStorage.retrieveMnemonic` → + // one-shot `dash_sdk_sign_with_mnemonic_and_path` + // FFI which derives the key, signs, and zeroes + // the buffers in-place. The derived bytes never + // cross the FFI back to Swift. + // + // Picking 0xFF for the platform-address tag keeps the FFI ABI + // single-byte and outside the standard `KeyType` enum range so + // future `KeyType` additions can't collide. See the matching + // `Signer` impl on `VTableSigner` in Rust. + + /// Discriminant byte the Rust FFI uses when shipping a 20-byte + /// platform-address hash through the trampoline instead of a raw + /// IdentityPublicKey blob. Mirrors + /// `SIGNER_KEY_TYPE_PLATFORM_ADDRESS_HASH` in `rs-sdk-ffi/src/signer.rs`. + fileprivate static let platformAddressHashKeyType: UInt8 = 0xFF + + /// Identity-key lookup: walk SwiftData → Keychain identifier → + /// Keychain bytes. Two-stage to handle both steady-state (after + /// registration, both the row and the keychain item are in place) + /// and mid-registration (the keychain item exists but the row is + /// inserted later by the Rust persister callback). + /// + /// Used by the `key_type < 5` branch only. The platform-address + /// branch (`key_type == 0xFF`) takes a different path — see + /// [`signPlatformAddressOnDemand`] — because platform-address + /// private keys are NEVER persisted; they're derived per call + /// from `(mnemonic, path)` inside Rust. + fileprivate func lookupIdentityPrivateKey( + publicKey: Data + ) -> Result { + var captured: Result = .failure(.publicKeyNotFound) + queue.sync { + let context = ModelContext(self.modelContainer) + let descriptor = FetchDescriptor( + predicate: #Predicate { row in + row.publicKeyData == publicKey + } + ) + + if let row = try? context.fetch(descriptor).first, + let identifier = row.privateKeyKeychainIdentifier, + let keyData = self.keychain.retrieveKeyData(identifier: identifier) + { + captured = .success(keyData) + return + } + + // Fallback: scan keychain entries by pubkey hex metadata. + // Used during identity registration when the SwiftData row + // hasn't landed yet but the keychain item has. + let pubkeyHex = publicKey.map { String(format: "%02x", $0) }.joined() + if let keyData = self.keychain.retrieveIdentityPrivateKey(publicKeyHex: pubkeyHex) { + captured = .success(keyData) + return + } + + captured = .failure(.publicKeyNotFound) + } + return captured + } + + /// True iff this signer can produce a signature for the + /// supplied `(publicKey, keyType)` pair. Mirrors the dispatch in + /// [`signOnDemand`]. + /// + /// For `keyType == 0xFF` we check that both the + /// `PersistentPlatformAddress.derivationPath` and the + /// per-wallet mnemonic Keychain item exist — the two inputs the + /// derive-and-sign FFI requires. We do NOT actually derive a key + /// here; the check is purely "are the prerequisites in place". + fileprivate func canSign(publicKey: Data, keyType: UInt8) -> Bool { + if keyType == Self.platformAddressHashKeyType { + // Resolve the address row first (synchronous lookup); + // mnemonic check is gated on having the wallet id. + guard let resolved = resolvePlatformAddressContext(addressHash: publicKey) else { + return false + } + // `WalletStorage.retrieveMnemonic` throws on miss; + // a successful return is sufficient to confirm presence. + return (try? WalletStorage().retrieveMnemonic(for: resolved.walletId)) != nil + } + + var found = false + queue.sync { + let context = ModelContext(self.modelContainer) + let descriptor = FetchDescriptor( + predicate: #Predicate { row in + row.publicKeyData == publicKey + } + ) + if let row = try? context.fetch(descriptor).first, + row.privateKeyKeychainIdentifier != nil + { + found = true + return + } + // Mirror `lookupIdentityPrivateKey`'s fallback: pre- + // registration the SwiftData row may not exist yet but + // the keychain item does. + let pubkeyHex = publicKey.map { String(format: "%02x", $0) }.joined() + if self.keychain.retrieveIdentityPrivateKey(publicKeyHex: pubkeyHex) != nil { + found = true + } + } + return found + } + + /// Resolved context for a platform-address signing request: + /// the matching `PersistentPlatformAddress`'s wallet id + + /// derivation path. `nil` when no row matches the supplied + /// 20-byte hash. + fileprivate struct PlatformAddressContext { + let walletId: Data + let derivationPath: String + } + + /// SwiftData lookup: 20-byte address hash → + /// `(walletId, derivationPath)`. Pinned to the signer's serial + /// queue + a per-call private `ModelContext` so the fetch is safe + /// off the main actor. + /// + /// Returns `nil` for two distinct reasons (collapsed for the + /// caller's convenience, since both end the request the same + /// way): no row matched the hash, OR the matched row had an + /// empty derivation path (which would indicate a corrupt + /// persister write — `PersistentPlatformAddress.derivationPath` + /// is non-optional and should always carry a DIP-17 path). + fileprivate func resolvePlatformAddressContext( + addressHash: Data + ) -> PlatformAddressContext? { + var resolved: PlatformAddressContext? = nil + queue.sync { + let context = ModelContext(self.modelContainer) + let descriptor = FetchDescriptor( + predicate: #Predicate { row in + row.addressHash == addressHash + } + ) + guard let row = try? context.fetch(descriptor).first else { + return + } + // Empty path = corrupt write; treat as unresolvable so + // the caller surfaces a precise error rather than handing + // an empty path to the FFI. + guard !row.derivationPath.isEmpty else { + return + } + resolved = PlatformAddressContext( + walletId: row.walletId, + derivationPath: row.derivationPath + ) + } + return resolved + } + + /// One-shot derive-and-sign for the `key_type == 0xFF` branch. + /// Pulls mnemonic from Keychain + derivation path from SwiftData, + /// then calls `dash_sdk_sign_with_mnemonic_and_path` which + /// derives the ECDSA key inside Rust, signs `data` with it, and + /// zeroes both the seed and the derived key buffer before + /// returning. The derived bytes never cross the FFI back to + /// Swift — only the signature does. + fileprivate func signPlatformAddressOnDemand( + addressHash: Data, + keyType: UInt8, + data: Data + ) -> Result { + let hashHex = addressHash.map { String(format: "%02x", $0) }.joined() + + // 1. Look up `(walletId, derivationPath)` on a private + // background ModelContext (off the main actor). + guard let ctx = resolvePlatformAddressContext(addressHash: addressHash) else { + return .failure(.platformAddressNotFound(addressHashHex: hashHex)) + } + + // 2. Pull mnemonic from Keychain. `WalletStorage` throws on + // miss; map to our typed error so the trampoline can + // surface a precise message. + let mnemonic: String + do { + mnemonic = try WalletStorage().retrieveMnemonic(for: ctx.walletId) + } catch { + let walletHex = ctx.walletId.map { String(format: "%02x", $0) }.joined() + return .failure(.mnemonicMissing(walletIdHex: walletHex)) + } + + // 3. One-shot FFI: derive → sign → zeroize. + // Buffer is sized generously (128B) — ECDSA compact + // recoverable signatures are 65 bytes; the cap leaves + // room for any future signature-shape additions through + // this entry point without an ABI change. + var sigBuf = [UInt8](repeating: 0, count: 128) + var sigLen: Int = 0 + var errTag: UInt8 = 0 + + // Defensive scrub of the local `sigBuf` regardless of + // success/failure. Swift can't truly zero a `String`, but + // `mnemonic` falls out of scope at function exit which is + // the best we can do without a native-Swift mnemonic type. + defer { + sigBuf.withUnsafeMutableBufferPointer { ptr in + if let base = ptr.baseAddress { + memset_s(UnsafeMutableRawPointer(base), ptr.count, 0, ptr.count) + } + } + } + + // Platform addresses are always ECDSA secp256k1 P2PKH. The + // `0xFF` value the trampoline received is the dispatch tag + // (`platformAddressHashKeyType`), used only to route to this + // branch — it is NOT a `KeyType` discriminant. Hardcode the + // real key type here; passing the dispatch tag through to + // `dash_sdk_sign_with_mnemonic_and_path` (which is ECDSA-only) + // would fail with `SIGN_WITH_MNEMONIC_ERR_UNSUPPORTED_KEY_TYPE`. + let ecdsaSecp256k1KeyType: UInt8 = 0 + let rc = mnemonic.withCString { mPtr -> Int32 in + ctx.derivationPath.withCString { pPtr -> Int32 in + data.withUnsafeBytes { dataRaw -> Int32 in + sigBuf.withUnsafeMutableBufferPointer { bufPtr -> Int32 in + let dataBase = dataRaw.bindMemory(to: UInt8.self).baseAddress + return dash_sdk_sign_with_mnemonic_and_path( + mPtr, + nil, // no BIP-39 passphrase yet (matches the rest of the SDK) + pPtr, + dataBase, + UInt(dataRaw.count), + ecdsaSecp256k1KeyType, + self.network, + bufPtr.baseAddress, + UInt(bufPtr.count), + &sigLen, + &errTag + ) + } + } + } + } + + guard rc == 0 else { + return .failure(.signWithMnemonicFailed(tag: errTag)) + } + + // Copy out the leading `sigLen` bytes BEFORE the deferred + // scrub erases them. + let signature = Data(sigBuf.prefix(sigLen)) + return .success(signature) + } + + /// v1 sign primitive. Wraps the raw 32-byte ECDSA scalar in a + /// throwaway FFI signer just long enough to produce a signature; + /// the keychain bytes are zeroed from the local copy as soon as + /// the FFI call returns. + /// + /// TODO(KeychainSigner v2): replace this whole function with a + /// native-Swift `secp256k1` invocation once we add the + /// `swift-secp256k1` SPM dep. + fileprivate func ffiSign( + privateKey: Data, + data: Data + ) -> Result { + // Defensive copy into a mutable buffer we can zero on exit. + var keyCopy = [UInt8](privateKey) + defer { + // Best-effort scrub. Swift doesn't guarantee this won't be + // optimised away, but an explicit `withContiguousMutableStorageIfAvailable` + // pattern does keep the touch in the IR. + keyCopy.withUnsafeMutableBufferPointer { buf in + if let base = buf.baseAddress { + memset_s(UnsafeMutableRawPointer(base), buf.count, 0, buf.count) + } + } + } + + let signerResult = keyCopy.withUnsafeBufferPointer { keyBuf -> DashSDKResult in + dash_sdk_signer_create_from_private_key( + keyBuf.baseAddress!, + UInt(keyBuf.count), + self.network + ) + } + + if let errPtr = signerResult.error { + let message = errPtr.pointee.message.map { String(cString: $0) } ?? "unknown" + dash_sdk_error_free(errPtr) + return .failure(.ffiSignerCreationFailed(message: message)) + } + guard let rawSigner = signerResult.data else { + return .failure(.ffiSignerCreationFailed(message: "null handle")) + } + let signerHandle = rawSigner.assumingMemoryBound(to: SignerHandle.self) + defer { dash_sdk_signer_destroy(signerHandle) } + + let signResult = data.withUnsafeBytes { dataBuf -> DashSDKResult in + dash_sdk_signer_sign( + signerHandle, + dataBuf.bindMemory(to: UInt8.self).baseAddress, + UInt(dataBuf.count) + ) + } + + if let errPtr = signResult.error { + let message = errPtr.pointee.message.map { String(cString: $0) } ?? "unknown" + dash_sdk_error_free(errPtr) + return .failure(.ffiSignFailed(message: message)) + } + guard let sigPtr = signResult.data else { + return .failure(.ffiSignFailed(message: "null signature")) + } + + let sigStruct = sigPtr.assumingMemoryBound(to: DashSDKSignature.self) + defer { dash_sdk_signature_free(sigStruct) } + + let sigBytes: Data + if let bytes = sigStruct.pointee.signature { + sigBytes = Data(bytes: bytes, count: Int(sigStruct.pointee.signature_len)) + } else { + sigBytes = Data() + } + return .success(sigBytes) + } + + // MARK: - Signer protocol conformance (legacy) + + /// Legacy `Signer` protocol path — exposed so views that still hold + /// a `Signer` (rather than a `KeychainSigner.handle`) keep + /// compiling during the FFI migration. Always treats the input + /// as an identity-key request (legacy callers never produced + /// platform-address requests) and routes through the same + /// SwiftData → Keychain identity-key lookup the trampoline uses. + public func sign(identityPublicKey: Data, data: Data) -> Data? { + switch lookupIdentityPrivateKey(publicKey: identityPublicKey) { + case .failure: + return nil + case .success(let priv): + switch ffiSign(privateKey: priv, data: data) { + case .success(let sig): return sig + case .failure: return nil + } + } + } + + public func canSign(identityPublicKey: Data) -> Bool { + canSign(publicKey: identityPublicKey, keyType: KeyType.ecdsaSecp256k1.rawValue) + } +} + +// MARK: - C-ABI trampolines + +/// `SignAsyncCallback` trampoline. Resolves the owning Swift instance +/// from the opaque `ctx` (which holds a `+1`-retained `KeychainSigner`), +/// performs the SwiftData lookup + sign, and invokes the Rust +/// completion synchronously before returning. +/// +/// Synchronous completion is fine here — the async-callback model is +/// designed for slow biometric prompts. v1 sign is fast enough that +/// dragging a thread back to a Tokio worker via `oneshot` is wasted +/// motion. +private func keychainSignerSignAsyncTrampoline( + ctx: UnsafeRawPointer?, + pubkeyBytes: UnsafePointer?, + pubkeyLen: UInt, + keyType: UInt8, + data: UnsafePointer?, + dataLen: UInt, + completionCtx: UnsafeMutableRawPointer?, + completion: SignCompletionCallback? +) { + guard let ctx, let completion else { return } + let signer = Unmanaged.fromOpaque(ctx).takeUnretainedValue() + + let pubkeyData: Data + if let pubkeyBytes, pubkeyLen > 0 { + pubkeyData = Data(bytes: pubkeyBytes, count: Int(pubkeyLen)) + } else { + pubkeyData = Data() + } + let dataToSign: Data + if let data, dataLen > 0 { + dataToSign = Data(bytes: data, count: Int(dataLen)) + } else { + dataToSign = Data() + } + + func reportError(_ message: String) { + // C strings have to outlive the call. `withCString` does this + // for us. + message.withCString { errPtr in + completion(completionCtx, nil, 0, errPtr) + } + } + + func reportSuccess(_ sig: Data) { + sig.withUnsafeBytes { sigBuf in + let base = sigBuf.bindMemory(to: UInt8.self).baseAddress + completion(completionCtx, base, UInt(sigBuf.count), nil) + } + } + + // Dispatch on key_type. Platform-address signing (`0xFF`) is a + // single-call derive-and-sign path — no separate key lookup, + // because the derived bytes never come back to Swift. Identity + // signing (`< 5`) keeps the original two-step `lookup → ffiSign` + // path because the identity key really does live in the Keychain. + if keyType == KeychainSigner.platformAddressHashKeyType { + switch signer.signPlatformAddressOnDemand( + addressHash: pubkeyData, + keyType: keyType, + data: dataToSign + ) { + case .failure(let err): + reportError(err.localizedDescription) + case .success(let sig): + reportSuccess(sig) + } + return + } + + let privateKey: Data + switch signer.lookupIdentityPrivateKey(publicKey: pubkeyData) { + case .failure(let err): + reportError(err.localizedDescription) + return + case .success(let priv): + privateKey = priv + } + + switch signer.ffiSign(privateKey: privateKey, data: dataToSign) { + case .failure(let err): + reportError(err.localizedDescription) + case .success(let sig): + reportSuccess(sig) + } +} + +private func keychainSignerCanSignTrampoline( + ctx: UnsafeRawPointer?, + pubkeyBytes: UnsafePointer?, + pubkeyLen: UInt, + keyType: UInt8 +) -> Bool { + guard let ctx else { return false } + let signer = Unmanaged.fromOpaque(ctx).takeUnretainedValue() + let pubkey: Data + if let pubkeyBytes, pubkeyLen > 0 { + pubkey = Data(bytes: pubkeyBytes, count: Int(pubkeyLen)) + } else { + pubkey = Data() + } + return signer.canSign(publicKey: pubkey, keyType: keyType) +} + +/// Vtable destructor — invoked exactly once when the FFI handle is +/// destroyed. Drops the `+1` retain captured by `init`. +private func keychainSignerDestroyTrampoline(ctx: UnsafeMutableRawPointer?) { + guard let ctx else { return } + Unmanaged.fromOpaque(ctx).release() +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/Signer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/Signer.swift index 89e55518fca..4b0406c35d3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/Signer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/Signer.swift @@ -2,71 +2,28 @@ import Foundation // MARK: - Signer Protocol -/// Protocol for signing operations -/// Implementations should securely store and retrieve private keys +/// Legacy Swift-side `Signer` protocol. +/// +/// `KeychainSigner` is the production implementation — it is also +/// what every FFI `_with_signer` entry point expects via its +/// `.handle` property. The protocol itself is kept (rather than +/// deleted) so callers that hold a generic `any Signer` reference +/// can keep compiling while migration to `KeychainSigner.handle` +/// proceeds. +/// +/// New code should depend on `KeychainSigner` directly and pass +/// `signer.handle` to FFI; this protocol does not (and cannot) +/// participate in the FFI signing path. public protocol Signer: Sendable { - /// Sign data using the private key corresponding to the given public key + /// Sign data using the private key corresponding to the given public key. /// - Parameters: - /// - identityPublicKey: The public key data identifying which private key to use - /// - data: The data to sign - /// - Returns: The signature data, or nil if signing failed + /// - identityPublicKey: The public key data identifying which private key to use. + /// - data: The data to sign. + /// - Returns: The signature data, or nil if signing failed. func sign(identityPublicKey: Data, data: Data) -> Data? - /// Check if this signer can sign for the given public key - /// - Parameter identityPublicKey: The public key data to check - /// - Returns: true if the signer has the corresponding private key + /// Check if this signer can sign for the given public key. + /// - Parameter identityPublicKey: The public key data to check. + /// - Returns: true if the signer has the corresponding private key. func canSign(identityPublicKey: Data) -> Bool } - -// MARK: - Test Signer - -/// Test signer implementation for development and testing -/// In production apps, use a secure signer that integrates with iOS Keychain -public final class TestSigner: Signer, @unchecked Sendable { - private var privateKeys: [String: Data] = [:] - - public init() { - // Initialize with some test private keys for demo purposes - // In a real app, these would be securely stored and retrieved - privateKeys["11111111111111111111111111111111"] = Data(repeating: 0x01, count: 32) - privateKeys["22222222222222222222222222222222"] = Data(repeating: 0x02, count: 32) - privateKeys["33333333333333333333333333333333"] = Data(repeating: 0x03, count: 32) - } - - public func sign(identityPublicKey: Data, data: Data) -> Data? { - // In a real implementation, this would: - // 1. Find the identity by its public key - // 2. Retrieve the corresponding private key from secure storage - // 3. Sign the data using the private key - // 4. Return the signature - - // For demo purposes, we'll create a mock signature - // based on the public key and data - var signature = Data() - signature.append(contentsOf: "SIGNATURE:".utf8) - signature.append(identityPublicKey.prefix(32)) - signature.append(data.prefix(32)) - - // Ensure signature is at least 64 bytes (typical for ECDSA) - while signature.count < 64 { - signature.append(0) - } - - return signature - } - - public func canSign(identityPublicKey: Data) -> Bool { - // In a real implementation, check if we have the private key - // corresponding to this public key - // For demo purposes, return true for known test identities - return true - } - - public func addPrivateKey(_ key: Data, forIdentity identityId: String) { - privateKeys[identityId] = key - } - - public func removePrivateKey(forIdentity identityId: String) { - privateKeys.removeValue(forKey: identityId) - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index bff4412eb8d..e0abd690c5b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -49,8 +49,13 @@ public enum DashModelContainer { cloudKitDatabase: cloudKit ? .automatic : .none ) + // Wire the migration plan even though V1 is the only shipped + // schema — future schema bumps just have to add a stage to + // `DashMigrationPlan.stages` without also having to remember + // to thread the plan into the container construction call. return try ModelContainer( for: schema, + migrationPlan: DashMigrationPlan.self, configurations: [modelConfiguration] ) } @@ -65,6 +70,7 @@ public enum DashModelContainer { return try ModelContainer( for: schema, + migrationPlan: DashMigrationPlan.self, configurations: [modelConfiguration] ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDataContract.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDataContract.swift index c3fd1b33676..e4cb045b96b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDataContract.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDataContract.swift @@ -30,7 +30,16 @@ public final class PersistentDataContract { public var groupsData: Data? // Network - public var network: AppNetwork + /// Stored as the `AppNetwork.rawValue` `Int` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + public var networkRaw: Int + + /// Type-safe accessor over `networkRaw`. Setter writes through. + public var network: AppNetwork { + get { AppNetwork(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } // Timestamps public var lastUpdated: Date @@ -190,7 +199,7 @@ public final class PersistentDataContract { self.documents = [] // Network and timestamps - self.network = network + self.networkRaw = network.rawValue self.lastUpdated = Date() self.lastSyncedAt = nil @@ -271,14 +280,19 @@ extension PersistentDataContract { } public static func predicate(network: AppNetwork) -> Predicate { - #Predicate { contract in - contract.network == network + // See `PersistentIdentity.predicate(network:)` — Foundation's + // predicate engine can't capture `AppNetwork`, so we filter on + // the Int-backed `networkRaw` shadow field. + let target = network.rawValue + return #Predicate { contract in + contract.networkRaw == target } } public static func contractsWithTokensPredicate(network: AppNetwork) -> Predicate { - #Predicate { contract in - contract.hasTokens == true && contract.network == network + let target = network.rawValue + return #Predicate { contract in + contract.hasTokens == true && contract.networkRaw == target } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocument.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocument.swift index aea92e39d15..7f03e417bed 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocument.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocument.swift @@ -36,7 +36,16 @@ public final class PersistentDocument { public var transferredAtCoreBlockHeight: Int64? // Network - public var network: AppNetwork + /// Stored as the `AppNetwork.rawValue` `Int` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + public var networkRaw: Int + + /// Type-safe accessor over `networkRaw`. Setter writes through. + public var network: AppNetwork { + get { AppNetwork(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } // Deletion flag public var isDeleted: Bool = false @@ -120,7 +129,7 @@ public final class PersistentDocument { self.ownerId = ownerId self.contractIdData = Data.identifier(fromBase58: contractId) ?? Data() self.ownerIdData = Data.identifier(fromBase58: ownerId) ?? Data() - self.network = network + self.networkRaw = network.rawValue self.createdAt = Date() self.updatedAt = Date() self.localCreatedAt = Date() @@ -151,8 +160,12 @@ public final class PersistentDocument { } public static func predicate(contractId: String, network: AppNetwork) -> Predicate { - #Predicate { doc in - doc.contractId == contractId && doc.network == network && doc.isDeleted == false + // See `PersistentIdentity.predicate(network:)` — Foundation's + // predicate engine can't capture `AppNetwork`, so we filter on + // the Int-backed `networkRaw` shadow field. + let target = network.rawValue + return #Predicate { doc in + doc.contractId == contractId && doc.networkRaw == target && doc.isDeleted == false } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift index 2e97028685e..f899c0a2711 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift @@ -28,7 +28,24 @@ public final class PersistentIdentity { public var lastSyncedAt: Date? // MARK: - Network - public var network: AppNetwork + /// Stored as the `AppNetwork.rawValue` `Int` so SwiftData + /// `#Predicate` expressions can evaluate it directly. Foundation's + /// predicate engine rejects captured non-primitive types — even + /// Codable raw-value enums crash at evaluation with + /// "Unsupported Predicate: Captured/constant values of type + /// 'AppNetwork' are not supported". The `network` computed + /// accessor below keeps the public API type-safe; only predicates + /// that need to filter by network reach for `networkRaw`. + public var networkRaw: Int + + /// Type-safe accessor over `networkRaw`. Reads fall back to + /// `.testnet` if the stored raw value ever drifts out of the + /// `AppNetwork` range (shouldn't happen — writers only go through + /// this setter which uses `AppNetwork.rawValue`). + public var network: AppNetwork { + get { AppNetwork(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } // MARK: - Wallet Association // @@ -83,7 +100,7 @@ public final class PersistentIdentity { self.votingPrivateKeyIdentifier = votingPrivateKeyIdentifier self.ownerPrivateKeyIdentifier = ownerPrivateKeyIdentifier self.payoutPrivateKeyIdentifier = payoutPrivateKeyIdentifier - self.network = network + self.networkRaw = network.rawValue self.identityIndex = identityIndex self.publicKeys = [] self.documents = [] @@ -198,14 +215,20 @@ extension PersistentIdentity { } public static func predicate(network: AppNetwork) -> Predicate { - #Predicate { identity in - identity.network == network + // Compare against the Int-backed `networkRaw` because Foundation's + // predicate evaluator can't capture non-primitive types like + // `AppNetwork` (the computed `network` accessor is invisible to + // SwiftData — it can't see through `\.network.rawValue` either). + let target = network.rawValue + return #Predicate { identity in + identity.networkRaw == target } } public static func localIdentitiesPredicate(network: AppNetwork) -> Predicate { - #Predicate { identity in - identity.isLocal == true && identity.network == network + let target = network.rawValue + return #Predicate { identity in + identity.isLocal == true && identity.networkRaw == target } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentSyncState.swift index 3c42ab0749e..0a3e0f45177 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentSyncState.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentSyncState.swift @@ -15,7 +15,17 @@ public final class PersistentSyncState { /// than a concrete wallet id. @Attribute(.unique) public var walletId: Data /// Network this sync checkpoint belongs to. - public var network: AppNetwork + /// + /// Stored as the `AppNetwork.rawValue` `Int` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + public var networkRaw: Int + + /// Type-safe accessor over `networkRaw`. Setter writes through. + public var network: AppNetwork { + get { AppNetwork(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } /// Highest block height covered by the last sync. public var syncHeight: UInt64 /// Latest timestamp covered by the last sync (Unix seconds). @@ -33,7 +43,7 @@ public final class PersistentSyncState { lastKnownRecentBlock: UInt64 ) { self.walletId = walletId - self.network = network + self.networkRaw = network.rawValue self.syncHeight = syncHeight self.syncTimestamp = syncTimestamp self.lastKnownRecentBlock = lastKnownRecentBlock diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenBalance.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenBalance.swift index 59a8dedf6dd..499f0a62391 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenBalance.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenBalance.swift @@ -21,7 +21,16 @@ public final class PersistentTokenBalance { public var tokenDecimals: Int32? // MARK: - Network - public var network: AppNetwork + /// Stored as the `AppNetwork.rawValue` `Int` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + public var networkRaw: Int + + /// Type-safe accessor over `networkRaw`. Setter writes through. + public var network: AppNetwork { + get { AppNetwork(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } // MARK: - Relationships @Relationship(deleteRule: .nullify) public var identity: PersistentIdentity? @@ -48,7 +57,7 @@ public final class PersistentTokenBalance { self.createdAt = Date() self.lastUpdated = Date() self.lastSyncedAt = nil - self.network = network + self.networkRaw = network.rawValue } // MARK: - Computed Properties diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 28dfcab5be7..54e0d4874b4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -13,7 +13,22 @@ public final class PersistentWallet { /// Network this wallet belongs to. `nil` means "not yet known" — /// the row was created by a changeset before `persistWalletMetadata` /// filled the network in. Views treat `nil` as unknown. - public var network: AppNetwork? + /// + /// Stored as the `AppNetwork.rawValue` `Int?` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + public var networkRaw: Int? + + /// Type-safe accessor over `networkRaw`. `nil` round-trips as + /// `nil`; non-nil reads fall back to `.testnet` if the stored + /// raw value ever drifts out of the `AppNetwork` range. + public var network: AppNetwork? { + get { + guard let raw = networkRaw else { return nil } + return AppNetwork(rawValue: raw) ?? .testnet + } + set { networkRaw = newValue?.rawValue } + } /// Optional wallet name. public var name: String? /// Birth height — block height when the wallet was created. @@ -71,7 +86,7 @@ public final class PersistentWallet { isImported: Bool = false ) { self.walletId = walletId - self.network = network + self.networkRaw = network?.rawValue self.name = name self.birthHeight = birthHeight self.syncedHeight = syncedHeight diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityRegistrationFFI.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityRegistrationFFI.swift index 698aa3ec153..de7a8902a32 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityRegistrationFFI.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityRegistrationFFI.swift @@ -10,7 +10,7 @@ import Foundation // MARK: - FFI Structs -/// Mirrors `IdentityInputAddressFFI` from Rust. +/// Mirrors `IdentityFundingInputFFI` from Rust. /// /// Nonces are intentionally absent — the SDK fetches each address's /// on-chain nonce at submit time, so callers only need to name the @@ -20,7 +20,7 @@ import Foundation /// - `hash`: 20-byte address hash (BE tuple of `UInt8` x 20). /// - `credits`: credits to spend from this address. @frozen -public struct IdentityInputAddressFFI { +public struct IdentityFundingInputFFI { public var address_type: UInt8 public var hash: ( UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, @@ -29,10 +29,10 @@ public struct IdentityInputAddressFFI { public var credits: UInt64 } -/// Mirrors `IdentityOutputAddressFFI` from Rust. When `has_output` is +/// Mirrors `IdentityFundingOutputFFI` from Rust. When `has_output` is /// false the remaining fields are ignored. @frozen -public struct IdentityOutputAddressFFI { +public struct IdentityFundingOutputFFI { public var has_output: Bool public var address_type: UInt8 public var hash: ( @@ -42,24 +42,86 @@ public struct IdentityOutputAddressFFI { public var credits: UInt64 } +/// Mirrors `IdentityPubkeyFFI` from Rust +/// (`packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs`). +/// +/// One already-derived authentication public key the caller wants the +/// new identity to register. Used by +/// `platform_wallet_register_identity_with_signer` in place of the +/// previous `key_count: UInt32` argument so that pubkey derivation can +/// happen entirely in Swift (via +/// `dash_sdk_derive_identity_keys_from_mnemonic`, which works for +/// watch-only wallets) rather than via the wallet handle on the Rust +/// side. +/// +/// Field discriminants match the DPP `repr(u8)` enum layouts: +/// - `key_type`: KeyType discriminant (0 = ECDSA_SECP256K1, etc.) +/// - `purpose`: Purpose discriminant (0 = AUTHENTICATION, etc.) +/// - `security_level`: SecurityLevel discriminant (0 = MASTER, 1 = +/// CRITICAL, 2 = HIGH, 3 = MEDIUM) +/// +/// `pubkey_bytes` is borrowed by the FFI for the duration of the call; +/// the caller retains ownership of the buffer. +@frozen +public struct IdentityPubkeyFFI { + public var key_id: UInt32 + public var key_type: UInt8 + public var purpose: UInt8 + public var security_level: UInt8 + public var pubkey_bytes: UnsafePointer? + public var pubkey_len: Int + public var read_only: Bool +} + // MARK: - FFI Functions -@_silgen_name("platform_wallet_register_identity_from_addresses") -func platform_wallet_register_identity_from_addresses( +// NOTE: The legacy mnemonic-driven `platform_wallet_register_identity_from_addresses` +// FFI has been deleted; the Rust crate no longer exports it. The +// signer-driven entry point below is the only supported path. + +/// Mirrors `platform_wallet_register_identity_with_signer` from Rust. +/// +/// Drops the BIP-39 mnemonic + passphrase parameters in favor of TWO +/// opaque `*mut SignerHandle`s — one for the new identity's +/// state-transition keys, one for each input platform address's +/// funding-contribution signature. The new identity's authentication +/// pubkeys are passed in by the caller via `identity_pubkeys` (an +/// array of `IdentityPubkeyFFI` rows) — Swift derives them via +/// `dash_sdk_derive_identity_keys_from_mnemonic` first, which works +/// for watch-only wallets where Rust has no in-process xpriv loaded. +/// Every signature crosses the FFI through the appropriate signer. +/// +/// The two-handle split keeps the FFI explicit about both signing +/// roles (callers pass the same `KeychainSigner.handle` for both in +/// the common iOS case; future watch-only / hardware paths can wire +/// distinct signers per role without another ABI change). The +/// underlying `VTableSigner` implements `Signer` +/// AND `Signer` and dispatches by `key_type` byte +/// (KeyType discriminants 0–4 → identity-key lookup; `0xFF` → +/// platform-address-hash lookup; see `KeychainSigner.swift`). +@_silgen_name("platform_wallet_register_identity_with_signer") +func platform_wallet_register_identity_with_signer( _ wallet_handle: Handle, _ identity_index: UInt32, - _ key_count: UInt32, - // UTF-8 null-terminated BIP-39 mnemonic; used only for the - // duration of this call to derive DIP-9 auth keys + sign. - _ mnemonic: UnsafePointer?, - // UTF-8 null-terminated BIP-39 passphrase; nil for empty. - _ passphrase: UnsafePointer?, - _ inputs: UnsafePointer?, + // Caller-derived authentication pubkeys for the new identity. + // Each row carries `(key_id, key_type, purpose, security_level, + // pubkey_bytes, pubkey_len, read_only)`. The buffer is borrowed — + // Rust copies what it needs before the call returns. + _ identity_pubkeys: UnsafePointer?, + _ identity_pubkeys_count: Int, + // Raw `*mut SignerHandle` produced by `dash_sdk_signer_create_with_ctx` + // (e.g. via `KeychainSigner.handle`). Used as `Signer`. + // Caller retains ownership; this function does NOT destroy it. + _ signer_identity_handle: OpaquePointer?, + // Raw `*mut SignerHandle`. Used as `Signer` — + // the trampoline receives `key_type == 0xFF` and a 20-byte + // address hash. Typically the same value as + // `signer_identity_handle`; pass distinct handles for watch-only + // wallets that route the two roles to different stores. + _ signer_address_handle: OpaquePointer?, + _ inputs: UnsafePointer?, _ inputs_count: Int, - // Passed by pointer, not value — C struct-by-value across - // the Swift/Rust boundary is brittle when the struct size - // straddles the 16-byte register-passing threshold. - _ output: UnsafePointer?, + _ output: UnsafePointer?, _ out_identity_id: UnsafeMutablePointer<( UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, @@ -69,3 +131,20 @@ func platform_wallet_register_identity_from_addresses( _ out_identity_handle: UnsafeMutablePointer, _ out_error: UnsafeMutablePointer ) -> PlatformWalletFFIResult + +// MARK: - Platform-address private-key pre-derivation — REMOVED +// +// The previous design pre-derived platform-address private keys in +// Rust, exported them across the FFI as 32-byte scalars, and had +// Swift persist them in the Keychain keyed by 20-byte address hash. +// That violated the rule that platform-address private keys are +// NEVER persisted — they are pure derivation outputs of +// `(mnemonic, path)` and exist only for the duration of a single +// signing call. +// +// The replacement is `dash_sdk_sign_with_mnemonic_and_path` in +// `KeychainSigner.swift`'s `key_type == 0xFF` branch: pull mnemonic +// from Keychain + derivation path from `PersistentPlatformAddress`, +// hand both to one Rust FFI call that derives, signs, and zeroes +// in-place. No `PlatformAddressPrivateKey(s)FFI`, no Keychain entry +// for the derived bytes, no exporting them across the FFI. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 54edd1b8c13..1bef67ed2d2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -173,6 +173,43 @@ public final class ManagedPlatformWallet: @unchecked Sendable { } } + /// One already-derived authentication public key the caller wants + /// the new identity to register. Marshalled into `IdentityPubkeyFFI` + /// at the FFI boundary by `registerIdentityFromAddresses`. + /// + /// Pre-derived in Swift via `prePersistIdentityKeysForRegistration` + /// (which routes through `dash_sdk_derive_identity_keys_from_mnemonic`, + /// the watch-only-safe derivation FFI). Threading these through to + /// Rust avoids the prior pubkey-derivation pass inside + /// `platform_wallet_register_identity_with_signer` that fails on + /// watch-only wallets where Rust has no in-process xpriv loaded. + public struct IdentityPubkey: Sendable { + public let keyId: UInt32 + public let keyType: KeyType + public let purpose: KeyPurpose + public let securityLevel: SecurityLevel + /// Serialized public key bytes (33 bytes for compressed + /// secp256k1; 48 for BLS; etc.). + public let pubkeyBytes: Data + public let readOnly: Bool + + public init( + keyId: UInt32, + keyType: KeyType, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + pubkeyBytes: Data, + readOnly: Bool = false + ) { + self.keyId = keyId + self.keyType = keyType + self.purpose = purpose + self.securityLevel = securityLevel + self.pubkeyBytes = pubkeyBytes + self.readOnly = readOnly + } + } + /// Result of a successful identity registration. public struct CreatedIdentity: Sendable { /// 32-byte identity id. @@ -187,62 +224,64 @@ public final class ManagedPlatformWallet: @unchecked Sendable { public let identity: ManagedIdentity } - /// Register a new identity funded by Platform-address balances. + /// Register a new identity funded by Platform-address balances, + /// using TWO external `SignerHandle`s — one for the new identity's + /// state-transition keys, one for the input platform addresses' + /// funding-contribution signatures. /// - /// The Rust side runs `IdentityWallet::register_from_addresses` - /// which derives DIP-9 authentication keys under - /// `m/9'/…/identityIndex'/key_index'`, builds an identity, and - /// submits via `IdentityCreateFromAddressesTransition`. The - /// call blocks until Platform confirms the transition, which can - /// take several seconds — it's expected to be driven from an - /// `async` context. + /// This is the preferred path: the wallet's mnemonic stays in + /// Keychain throughout, and every signature crosses the FFI + /// through one of the supplied signers (typically two views of + /// the same `KeychainSigner` — see the convenience overload + /// below). Per `swift-sdk/CLAUDE.md`, the seed must not cross + /// the FFI boundary just so Rust can finish an operation. + /// + /// The two-handle FFI shape unblocks watch-only wallets and + /// future Keychain-backed platform-address keys (each role can + /// route to its own backing store) without an ABI change later. /// /// - Parameters: - /// - inputs: Contributing addresses with the credit amount to - /// spend from each. Nonces are resolved by the SDK at submit - /// time — the caller does not track them. - /// - output: Optional refund address + credits. `nil` when any - /// residual should go into the new identity. + /// - inputs: contributing address rows describing each input + /// platform address + the credit amount to spend from it. + /// - output: optional refund output. /// - identityIndex: BIP-9 identity index in the HD tree. - /// - keyCount: Number of authentication keys to register - /// (must be ≥ 1). First key is MASTER, the rest HIGH. - /// - mnemonic: BIP-39 mnemonic phrase (English). The Rust - /// side uses it to derive DIP-9 auth keys + sign both the - /// new identity and each spent platform address. Not - /// retained beyond this call. - /// - passphrase: Optional BIP-39 passphrase. `nil` encodes - /// the empty passphrase. + /// - identityPubkeys: Already-derived authentication pubkeys + /// for the new identity (typically produced by + /// `prePersistIdentityKeysForRegistration`). The first row + /// should be the MASTER key; the remainder are HIGH-security + /// authentication keys. Threading the pubkeys in unblocks + /// watch-only wallets — Rust no longer needs the seed to + /// build the placeholder identity. + /// - identitySigner: signer whose `.handle` produces signatures + /// for the new identity's authentication keys. Borrowed for + /// the duration of the call. + /// - addressSigner: signer whose `.handle` produces signatures + /// for each input platform address. Borrowed for the + /// duration of the call. May be the same instance as + /// `identitySigner`. public func registerIdentityFromAddresses( inputs: [IdentityAddressInput], output: IdentityAddressOutput?, identityIndex: UInt32, - keyCount: UInt32, - mnemonic: String, - passphrase: String? = nil + identityPubkeys: [IdentityPubkey], + identitySigner: KeychainSigner, + addressSigner: KeychainSigner ) async throws -> CreatedIdentity { guard !inputs.isEmpty else { throw PlatformWalletError.invalidParameter } - guard !mnemonic.isEmpty else { + guard !identityPubkeys.isEmpty else { throw PlatformWalletError.invalidParameter } - // Copy inputs into the flat FFI shape. Do it off the main - // actor via `Task.detached` because `platform_wallet_register_ - // identity_from_addresses` internally drives a tokio runtime - // via `block_on`; calling from the main thread would park - // user-interactive QoS on default-QoS workers (same pattern - // we use for BLAST sync). - // - // The detached task returns primitive `(Data, Handle)` which - // are `Sendable`. The `ManagedIdentity` wrapper is constructed - // back in the calling isolation domain so we don't need to - // add a Sendable bound on the non-sendable FFI wrapper type. let handle = self.handle + let identitySignerHandle = identitySigner.handle + let addressSignerHandle = addressSigner.handle + let (idData, identityHandle): (Data, Handle) = try await Task.detached( priority: .userInitiated ) { () -> (Data, Handle) in - let ffiInputs = inputs.map { input -> IdentityInputAddressFFI in + let ffiInputs = inputs.map { input -> IdentityFundingInputFFI in var hashTuple = hashTupleInit() withUnsafeMutableBytes(of: &hashTuple) { raw in let src = input.hash.prefix(20) @@ -250,17 +289,14 @@ public final class ManagedPlatformWallet: @unchecked Sendable { raw[i] = byte } } - return IdentityInputAddressFFI( + return IdentityFundingInputFFI( address_type: input.addressType, hash: hashTuple, credits: input.credits ) } - // Build a single FFI output struct; we pass it by - // pointer (`&outputFFI`) below. `has_output=false` - // tells Rust to ignore the rest of the fields. - var outputFFI: IdentityOutputAddressFFI = { + var outputFFI: IdentityFundingOutputFFI = { if let output { var hashTuple = hashTupleInit() withUnsafeMutableBytes(of: &hashTuple) { raw in @@ -269,14 +305,14 @@ public final class ManagedPlatformWallet: @unchecked Sendable { raw[i] = byte } } - return IdentityOutputAddressFFI( + return IdentityFundingOutputFFI( has_output: true, address_type: output.addressType, hash: hashTuple, credits: output.credits ) } else { - return IdentityOutputAddressFFI( + return IdentityFundingOutputFFI( has_output: false, address_type: 0, hash: hashTupleInit(), @@ -299,20 +335,36 @@ public final class ManagedPlatformWallet: @unchecked Sendable { var outIdentityHandle: Handle = NULL_HANDLE var error = PlatformWalletFFIError() - // `withCString` guarantees a null-terminated UTF-8 - // buffer valid for the closure's lifetime. The Rust - // side copies what it needs and does not retain the - // pointer. - let result = mnemonic.withCString { mnemonicPtr -> PlatformWalletFFIResult in - let call: (UnsafePointer?) -> PlatformWalletFFIResult = { passphrasePtr in - ffiInputs.withUnsafeBufferPointer { inputsBuf in - withUnsafePointer(to: &outputFFI) { outputPtr in - platform_wallet_register_identity_from_addresses( + // Stage each pubkey into a contiguous, owning buffer so + // the `pubkey_bytes` pointers we hand to Rust stay valid + // for the full duration of the FFI call. Building two + // parallel arrays — one of `Data` keeping the bytes + // alive, one of `IdentityPubkeyFFI` rows pointing into + // them — and pinning both via nested + // `withUnsafeBufferPointer` is the simplest shape that + // matches the Rust side's borrow-for-the-call contract. + let pubkeyBuffers: [Data] = identityPubkeys.map { $0.pubkeyBytes } + + let result = ffiInputs.withUnsafeBufferPointer { inputsBuf in + withUnsafePointer(to: &outputFFI) { outputPtr in + pubkeyBuffers.withUnsafeBufferPointer { _ -> PlatformWalletFFIResult in + // For each row, withUnsafeBytes pins ONE Data + // at a time, so we have to assemble the FFI + // row array under nested pinning. We use a + // recursive helper-via-fold by building the + // array of FFI rows lazily through nested + // `withUnsafeBytes` calls. + return Self.withPubkeyFFIArray( + identityPubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + platform_wallet_register_identity_with_signer( handle, identityIndex, - keyCount, - mnemonicPtr, - passphrasePtr, + ffiRowsPtr, + ffiRowsCount, + identitySignerHandle, + addressSignerHandle, inputsBuf.baseAddress, inputsBuf.count, outputPtr, @@ -323,11 +375,6 @@ public final class ManagedPlatformWallet: @unchecked Sendable { } } } - if let passphrase { - return passphrase.withCString { passphrasePtr in call(passphrasePtr) } - } else { - return call(nil) - } } guard result == Success else { @@ -346,10 +393,92 @@ public final class ManagedPlatformWallet: @unchecked Sendable { identity: ManagedIdentity(handle: identityHandle) ) } + + /// Convenience overload — uses the same `KeychainSigner` for both + /// the identity-key role and the platform-address role. The + /// trampoline dispatches by `key_type` byte (KeyType discriminants + /// 0–4 → `PersistentPublicKey` lookup; `0xFF` → 20-byte address + /// hash lookup), so a single Swift signer can serve both. This + /// matches today's iOS wallet shape; the two-signer overload + /// above is the building block for future watch-only / hardware + /// flows. + public func registerIdentityFromAddresses( + inputs: [IdentityAddressInput], + output: IdentityAddressOutput?, + identityIndex: UInt32, + identityPubkeys: [IdentityPubkey], + signer: KeychainSigner + ) async throws -> CreatedIdentity { + try await registerIdentityFromAddresses( + inputs: inputs, + output: output, + identityIndex: identityIndex, + identityPubkeys: identityPubkeys, + identitySigner: signer, + addressSigner: signer + ) + } + + /// Pin every pubkey buffer simultaneously and call `body` with a + /// freshly-built `[IdentityPubkeyFFI]` whose `pubkey_bytes` + /// pointers all reference the pinned bytes. Recursive shape + /// because Swift's `withUnsafeBytes` only pins one `Data` at a + /// time — recursing pins them in order, then runs `body` once at + /// the deepest frame where every buffer is alive simultaneously. + /// + /// `pubkeys[i]` must align with `buffers[i]` (the latter is a + /// pre-extracted `[Data]` of the same `pubkeyBytes` values, kept + /// separately so the recursive helper doesn't need to see the + /// full Swift wrapper struct). + fileprivate static func withPubkeyFFIArray( + _ pubkeys: [IdentityPubkey], + buffers: [Data], + _ body: (UnsafePointer?, Int) -> R + ) -> R { + precondition(pubkeys.count == buffers.count, "pubkeys / buffers length mismatch") + var rows: [IdentityPubkeyFFI] = [] + rows.reserveCapacity(pubkeys.count) + return pinNext(0, &rows, pubkeys, buffers, body) + } + + /// Inner recursion for `withPubkeyFFIArray`. `index` advances + /// through `pubkeys`; on each step we pin the next `Data` via + /// `withUnsafeBytes`, append a matching FFI row, and recurse. + /// At index == count we hand the assembled row array off to + /// `body` under one combined pinning frame. + private static func pinNext( + _ index: Int, + _ rows: inout [IdentityPubkeyFFI], + _ pubkeys: [IdentityPubkey], + _ buffers: [Data], + _ body: (UnsafePointer?, Int) -> R + ) -> R { + if index == pubkeys.count { + return rows.withUnsafeBufferPointer { rowsBuf in + body(rowsBuf.baseAddress, rowsBuf.count) + } + } + let pk = pubkeys[index] + return buffers[index].withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> R in + let basePtr = raw.bindMemory(to: UInt8.self).baseAddress + rows.append( + IdentityPubkeyFFI( + key_id: pk.keyId, + key_type: pk.keyType.ffiValue, + purpose: pk.purpose.ffiValue, + security_level: pk.securityLevel.ffiValue, + pubkey_bytes: basePtr, + pubkey_len: raw.count, + read_only: pk.readOnly + ) + ) + return pinNext(index + 1, &rows, pubkeys, buffers, body) + } + } } /// All-zero 20-byte tuple — used as the `hash` field default when -/// building `IdentityInputAddressFFI` / `IdentityOutputAddressFFI`. +/// building `IdentityFundingInputFFI` / `IdentityFundingOutputFFI`. private func hashTupleInit() -> ( UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 @@ -382,13 +511,26 @@ extension ManagedPlatformWallet { /// Full derivation path string, e.g. /// `m/9'/1'/5'/0'/0'/0'/0'`. public let derivationPath: String + /// Compressed public key bytes (raw — 33 bytes for ECDSA + /// secp256k1). Use `publicKeyHex` for display, `publicKeyData` + /// for keychain metadata / SwiftData rows. + public let publicKeyData: Data /// Compressed public key bytes as lowercase hex (33 bytes → - /// 66 hex chars). + /// 66 hex chars). Convenience over `publicKeyData`. public let publicKeyHex: String /// Private key in WIF (Wallet Import Format) — network-aware, /// compressed. Matches how other views in the example app /// accept / display private keys. public let privateKeyWIF: String + /// Raw 32-byte ECDSA private-key scalar. Sensitive material — + /// the intended use is to immediately persist into the iOS + /// Keychain via + /// `KeychainManager.storeIdentityPrivateKey(_:derivationPath:metadata:)` + /// and drop the local reference. The pre-registration helper + /// `prePersistIdentityKeysForRegistration` does this for the + /// caller; surface it here too so other diagnostic views can + /// inspect / re-stash a key without a second derivation pass. + public let privateKeyData: Data public var id: UInt32 { identityIndex } } @@ -464,26 +606,229 @@ extension ManagedPlatformWallet { let path: String = row.derivation_path.map { String(cString: $0) } ?? "" let wif: String = row.private_key_wif.map { String(cString: $0) } ?? "" + let pubData: Data let pubHex: String if let pubPtr = row.public_key, row.public_key_len > 0 { - let buf = UnsafeBufferPointer(start: pubPtr, count: row.public_key_len) - pubHex = buf.map { String(format: "%02x", $0) }.joined() + pubData = Data(bytes: pubPtr, count: row.public_key_len) + pubHex = pubData.map { String(format: "%02x", $0) }.joined() } else { + pubData = Data() pubHex = "" } + // Inline 32-byte tuple → owned `Data`. We copy because + // the underlying tuple is freed when the FFI struct is + // released by the deferred free call. + var pkTuple = row.private_key_bytes + let pkData = withUnsafeBytes(of: &pkTuple) { Data($0) } + previews.append( IdentityRegistrationKeyPreview( identityIndex: row.identity_index, derivationPath: path, + publicKeyData: pubData, publicKeyHex: pubHex, - privateKeyWIF: wif + privateKeyWIF: wif, + privateKeyData: pkData ) ) } return previews } + /// Pre-derive + pre-persist the authentication keys an upcoming + /// `registerIdentityFromAddresses(...signer:)` call will use. + /// + /// Required because the FFI `KeychainSigner` looks up private + /// keys by public-key bytes, but the `PersistentPublicKey` + /// SwiftData rows + their keychain entries are normally only + /// inserted by the Rust persister callback **after** registration + /// completes. Calling this method before registration writes the + /// keychain entries up front so the signer trampoline can find + /// them mid-registration via the + /// `KeychainManager.retrieveIdentityPrivateKey(publicKeyHex:)` + /// fallback path. The matching SwiftData rows are written by + /// the persister callback once registration succeeds. + /// + /// All derivation happens entirely on the Rust side via + /// `dash_sdk_derive_identity_keys_from_mnemonic`. Swift's role is + /// purely (a) pulling the mnemonic out of Keychain and (b) writing + /// the resulting private keys back into Keychain — the only + /// operations Rust cannot perform from its side, per + /// `swift-sdk/CLAUDE.md`. + /// + /// # Why this routes through the mnemonic-driven FFI + /// + /// The previous implementation called the wallet-handle variant + /// (`platform_wallet_derive_identity_keys_for_index`), which + /// required Rust to have an in-process xpriv for this wallet. That + /// works for wallets created in the current process but fails with + /// `"Cannot derive private keys from watch-only wallet"` for + /// wallets restored from SwiftData persistence (their seed lives + /// in Keychain, not in the `WalletManager`). Pulling the mnemonic + /// from Keychain per call and handing it to a stateless derivation + /// FFI works for every wallet shape regardless of how it was + /// loaded into the process. + /// + /// - Parameters: + /// - identityIndex: The identity index that will be passed to + /// `registerIdentityFromAddresses`. + /// - keyCount: The number of authentication keys to pre-derive. + /// Must match the `keyCount` argument used at registration + /// time so the `(identityIndex, keyId)` slots line up with + /// the eventual persister-callback rows. + /// - network: The network the upcoming registration will run + /// on. Selects the DIP-9 coin-type slot in the derivation + /// paths AND the WIF version byte. Must match the network of + /// the SDK / `KeychainSigner` used at registration time. + /// - walletIdHex: Hex-encoded wallet id used in the keychain + /// metadata blob — surfaced by the keychain-explorer view + /// and by `PersistentPublicKey` later. Should match the + /// wallet's `walletId` (same value the Rust persister + /// callback would write). + /// - keychain: Defaults to `KeychainManager.shared`. + /// - storage: `WalletStorage` instance used to read the BIP-39 + /// mnemonic from Keychain. Defaults to a fresh + /// `WalletStorage()` — overridable for tests. + /// - Returns: The previews that were just persisted (in the same + /// order as `keyCount`), so callers can hold on to the pubkey + /// data if they need to render anything else. + @discardableResult + public func prePersistIdentityKeysForRegistration( + identityIndex: UInt32, + keyCount: UInt32, + network: DashSDKNetwork, + walletIdHex: String, + keychain: KeychainManager = .shared, + storage: WalletStorage = WalletStorage() + ) throws -> [IdentityRegistrationKeyPreview] { + guard keyCount > 0 else { return [] } + + // Pull the BIP-39 mnemonic out of Keychain. `String` can't be + // truly zeroed in Swift, but we keep the local lifetime as + // narrow as possible — only this function holds a reference, + // and the FFI call inside copies the bytes onto the Rust side + // (where they ARE held in `Zeroizing` and scrubbed at function + // exit). + let mnemonic = try storage.retrieveMnemonic(for: self.walletId) + + var out = identityRegistrationKeyDerivationsFFIEmpty() + var error = PlatformWalletFFIError() + let result = mnemonic.withCString { mPtr -> PlatformWalletFFIResult in + // No BIP-39 passphrase is supported by the rest of the + // SDK yet (mirrors `KeychainSigner.swift`'s own + // `dash_sdk_sign_with_mnemonic_and_path` call site). + // Pass `nil` to mean "empty passphrase". + dash_sdk_derive_identity_keys_from_mnemonic( + mPtr, + nil, + network, + identityIndex, + keyCount, + &out, + &error + ) + } + defer { dash_sdk_derive_identity_keys_from_mnemonic_free(&out) } + + guard result == Success else { + throw PlatformWalletError(result: result, error: error) + } + + guard let base = out.items, out.count > 0 else { return [] } + + var persisted: [IdentityRegistrationKeyPreview] = [] + persisted.reserveCapacity(out.count) + + for i in 0.. 0 { + pubData = Data(bytes: pubPtr, count: row.public_key_len) + pubHex = pubData.map { String(format: "%02x", $0) }.joined() + } else { + pubData = Data() + pubHex = "" + } + + var pkTuple = row.private_key_bytes + let pkData = withUnsafeBytes(of: &pkTuple) { Data($0) } + + // Identity-id is unknown pre-registration (Rust + // recomputes it from the input addresses). Use the + // marker `pending` so the keychain explorer makes it + // obvious which rows are pre-registered slots. + // + // Discriminant convention matches `CreateIdentityView`'s + // `IdentityPubkey` mapping (and the upstream Rust-side + // policy that lived inside `register_identity` before it + // moved to Swift): + // - keyId 0 → MASTER, AUTHENTICATION, ECDSA_SECP256K1 + // - keyId > 0 → HIGH, AUTHENTICATION, ECDSA_SECP256K1 + // The bytes used here (`0` / `1` / `0`) are the + // canonical DPP `repr(u8)` discriminants, the same ones + // every other FFI surface in this SDK speaks. + let pubHashHex = KeychainManager.computePublicKeyHashHex(pubData) + // CreateIdentityView's enum mapping uses .high for + // non-master rows; the DPP `SecurityLevel` discriminant + // for HIGH is 2 (0=MASTER, 1=CRITICAL, 2=HIGH, 3=MEDIUM). + // Mirror that here so the Keychain row stamps the same + // security level the pubkey row actually submitted to + // the network carries. + let secLevelByte: UInt8 = i == 0 ? 0 /* MASTER */ : 2 /* HIGH */ + let metadata = KeychainManager.IdentityPrivateKeyMetadata( + identityId: "pending", + keyId: UInt32(i), + walletId: walletIdHex, + identityIndex: identityIndex, + keyIndex: UInt32(i), + derivationPath: path, + publicKey: pubHex, + publicKeyHash: pubHashHex, + keyType: 0, // ECDSA_SECP256K1 + purpose: 0, // AUTHENTICATION + securityLevel: secLevelByte + ) + _ = keychain.storeIdentityPrivateKey( + pkData, + derivationPath: path, + metadata: metadata + ) + + persisted.append( + IdentityRegistrationKeyPreview( + identityIndex: identityIndex, + derivationPath: path, + publicKeyData: pubData, + publicKeyHex: pubHex, + privateKeyWIF: wif, + privateKeyData: pkData + ) + ) + } + + return persisted + } + + // ---------------------------------------------------------------- + // prePersistPlatformAddressPrivateKeysForRegistration — REMOVED + // ---------------------------------------------------------------- + // + // Platform-address private keys are NEVER persisted. The FFI + // signer trampoline derives them on demand per signing call from + // `(mnemonic-in-Keychain, derivation-path-in-SwiftData)` via + // `dash_sdk_sign_with_mnemonic_and_path` and zeroes the buffers + // immediately. See `KeychainSigner.swift`'s `key_type == 0xFF` + // branch. + // + // Identity keys still go through `prePersistIdentityKeysForRegistration` + // above — those ARE intended to live in Keychain as primary + // storage. + /// Scan the wallet's DIP-9 identity authentication derivation /// tree for registered identities and fold any matches into the /// local identity manager. @@ -557,6 +902,18 @@ public struct DpnsSearchResult: Sendable, Equatable { extension ManagedPlatformWallet { /// Register a DPNS name for `identityId` on Platform. /// + /// # Superseded — prefer the `signer:`-taking overload + /// + /// This variant routes through the legacy + /// `platform_wallet_register_dpns_name` FFI which constructs an + /// internal `IdentitySigner` from the wallet manager. That path + /// dies on watch-only wallets (no seed Rust-side) and can also + /// deadlock the Tokio worker when its derivation tries to + /// `blocking_read` the wallet-manager lock from inside a signing + /// future. New call sites should use the + /// `registerDpnsName(identityId:name:signer:)` overload below + /// and pass a `KeychainSigner`. + /// /// Goes through `IdentityWallet::register_name`, which on success: /// 1. broadcasts the DPNS preorder + domain documents /// 2. appends the new `DpnsNameInfo` to @@ -606,6 +963,81 @@ extension ManagedPlatformWallet { }.value } + /// Register a DPNS name for `identityId` on Platform using an + /// externally-supplied `KeychainSigner`. + /// + /// Architecturally aligned with + /// `registerIdentityFromAddresses(...identitySigner:addressSigner:)`: + /// every signature on this path crosses the FFI boundary via the + /// Swift-side signer's vtable, so the wallet's own seed never + /// participates. Required for watch-only wallets restored from + /// SwiftData state (where the seed lives in iOS Keychain rather + /// than the in-process `WalletManager`) and avoids the deadlock + /// the legacy `IdentitySigner`-based variant hit on the Tokio + /// worker. + /// + /// Goes through `IdentityWallet::register_name_with_external_signer`, + /// which on success: + /// 1. broadcasts the DPNS preorder + domain documents (signing + /// via the supplied `signer.handle`), + /// 2. appends the new `DpnsNameInfo` to + /// `ManagedIdentity.dpns_names`, + /// 3. queues the updated identity in the persister so the + /// SwiftData `PersistentIdentity` row refreshes via the + /// `on_persist_identities_fn` callback. + /// + /// The Rust side picks the HIGH/CRITICAL authentication key the + /// document state transition requires from the identity's own + /// `public_keys` map; the signer's role is to sign with whatever + /// key was picked. + /// + /// Returns the full domain name (e.g. `"alice.dash"`). + @discardableResult + public func registerDpnsName( + identityId: Identifier, + name: String, + signer: KeychainSigner + ) async throws -> String { + let handle = self.handle + // Take the raw signer handle outside the Task — `KeychainSigner` + // owns its handle for its full lifetime (released in `deinit`), + // so the pointer is safe to capture into the detached Task as + // long as the caller keeps `signer` alive across the await. + let signerHandle = signer.handle + // Capture the 32-byte payload by value into a Sendable + // `[UInt8]` so the detached Task can hand a fresh pointer + // back to the FFI. + let idBytes: [UInt8] = identityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + return try await Task.detached(priority: .userInitiated) { () -> String in + var outPtr: UnsafeMutablePointer? = nil + var error = PlatformWalletFFIError() + let result = idBytes.withUnsafeBufferPointer { idBp in + name.withCString { namePtr in + platform_wallet_register_dpns_name_with_signer( + handle, + idBp.baseAddress!, + namePtr, + signerHandle, + &outPtr, + &error + ) + } + } + guard result == Success else { + throw PlatformWalletError(result: result, error: error) + } + defer { if let p = outPtr { platform_wallet_string_free(p) } } + guard let p = outPtr else { + throw PlatformWalletError.walletOperation( + "register_dpns_name_with_signer returned a null full-domain-name pointer" + ) + } + return String(cString: p) + }.value + } + /// Resolve a DPNS name (`"alice"` or `"alice.dash"`) to an /// identity id. Returns `nil` when the name is unregistered. public func resolveDpnsName(_ name: String) async throws -> Identifier? { @@ -959,6 +1391,122 @@ extension ManagedPlatformWallet { return EstablishedContact(handle: establishedHandle) } + /// Send a contact request using an externally-supplied + /// `KeychainSigner` for the document state-transition. + /// + /// Architecturally aligned with the other `_with_signer` flows + /// (DPNS register, identity register, ...). Required for + /// watch-only wallets and the architecturally correct path per + /// `swift-sdk/CLAUDE.md` — no signing happens via the wallet's + /// own seed on this path. + /// + /// CAVEAT — ECDH derivation: the contact-request encryption step + /// still derives the sender's ECDH private key from the wallet + /// seed Rust-side. Watch-only wallets will fail at that step + /// until a follow-up FFI pushes ECDH across as well. + public func sendContactRequest( + senderIdentityId: Identifier, + recipientIdentityId: Identifier, + accountLabel: String? = nil, + autoAcceptProof: Data? = nil, + signer: KeychainSigner + ) async throws -> ContactRequest { + let handle = self.handle + let signerHandle = signer.handle + let senderBytes: [UInt8] = senderIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let recipientBytes: [UInt8] = recipientIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let accountLabel = accountLabel + let autoAcceptProof = autoAcceptProof + + let requestHandle: Handle = try await Task.detached(priority: .userInitiated) { + () -> Handle in + var outHandle: Handle = NULL_HANDLE + var error = PlatformWalletFFIError() + let result: PlatformWalletFFIResult = senderBytes.withUnsafeBufferPointer { + senderBp -> PlatformWalletFFIResult in + recipientBytes.withUnsafeBufferPointer { recipientBp -> PlatformWalletFFIResult in + let callWithLabel: (UnsafePointer?) -> PlatformWalletFFIResult = { + labelPtr in + if let autoAcceptProof, !autoAcceptProof.isEmpty { + return autoAcceptProof.withUnsafeBytes { rawBuf in + let bytesPtr = rawBuf.baseAddress? + .assumingMemoryBound(to: UInt8.self) + return platform_wallet_send_contact_request_with_signer( + handle, + senderBp.baseAddress!, + recipientBp.baseAddress!, + labelPtr, + bytesPtr, + autoAcceptProof.count, + signerHandle, + &outHandle, + &error + ) + } + } else { + return platform_wallet_send_contact_request_with_signer( + handle, + senderBp.baseAddress!, + recipientBp.baseAddress!, + labelPtr, + nil, + 0, + signerHandle, + &outHandle, + &error + ) + } + } + if let accountLabel { + return accountLabel.withCString { callWithLabel($0) } + } else { + return callWithLabel(nil) + } + } + } + guard result == Success else { + throw PlatformWalletError(result: result, error: error) + } + return outHandle + }.value + + return ContactRequest(handle: requestHandle) + } + + /// Accept an incoming contact request using an externally-supplied + /// `KeychainSigner` for the reciprocal request's document + /// state-transition. + public func acceptContactRequest( + _ request: ContactRequest, + signer: KeychainSigner + ) async throws -> EstablishedContact { + let walletHandle = self.handle + let requestHandle = request.handle + let signerHandle = signer.handle + let establishedHandle: Handle = try await Task.detached( + priority: .userInitiated + ) { () -> Handle in + var outHandle: Handle = NULL_HANDLE + var error = PlatformWalletFFIError() + let result = platform_wallet_accept_contact_request_with_signer( + walletHandle, + requestHandle, + signerHandle, + &outHandle, + &error + ) + guard result == Success else { + throw PlatformWalletError(result: result, error: error) + } + return outHandle + }.value + return EstablishedContact(handle: establishedHandle) + } + /// Reject an incoming contact request. Today the effect is /// local — drops it from `ManagedIdentity.incoming_contact_requests`. /// A future follow-up (TODO in the Rust `reject_contact_request`) @@ -1191,6 +1739,118 @@ extension ManagedPlatformWallet { ) } + /// Create a new DashPay profile using an externally-supplied + /// `KeychainSigner` for the document state-transition. + /// + /// Architecturally aligned with `registerDpnsName(...signer:)` — + /// the wallet's own seed never participates Rust-side. + @discardableResult + public func createDashPayProfile( + identityId: Identifier, + update: DashPayProfileUpdate, + signer: KeychainSigner + ) async throws -> DashPayProfile { + try await submitDashPayProfileWithSigner( + identityId: identityId, + update: update, + doCreate: true, + signer: signer + ) + } + + /// Update an existing DashPay profile using an + /// externally-supplied `KeychainSigner`. + @discardableResult + public func updateDashPayProfile( + identityId: Identifier, + update: DashPayProfileUpdate, + signer: KeychainSigner + ) async throws -> DashPayProfile { + try await submitDashPayProfileWithSigner( + identityId: identityId, + update: update, + doCreate: false, + signer: signer + ) + } + + /// Shared submit path for the signer-driven profile flows. Mirrors + /// `submitDashPayProfile` but routes through + /// `platform_wallet_create_or_update_dashpay_profile_with_signer`. + private func submitDashPayProfileWithSigner( + identityId: Identifier, + update: DashPayProfileUpdate, + doCreate: Bool, + signer: KeychainSigner + ) async throws -> DashPayProfile { + let handle = self.handle + let signerHandle = signer.handle + let idBytes: [UInt8] = identityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let displayName = update.displayName + let publicMessage = update.publicMessage + let avatarUrl = update.avatarUrl + let avatarBytes = update.avatarBytes + + return try await Task.detached(priority: .userInitiated) { () -> DashPayProfile in + var outProfile = dashPayProfileFFIEmpty() + var error = PlatformWalletFFIError() + + let result: PlatformWalletFFIResult = idBytes.withUnsafeBufferPointer { + idBp -> PlatformWalletFFIResult in + let idPtr = idBp.baseAddress! + return invokeWithOptionalCStrings( + displayName, + publicMessage, + avatarUrl + ) { namePtr, msgPtr, urlPtr -> PlatformWalletFFIResult in + let bytes = avatarBytes ?? Data() + if let avatarBytes, !avatarBytes.isEmpty { + return avatarBytes.withUnsafeBytes { rawBuf -> PlatformWalletFFIResult in + let bytesPtr = rawBuf.baseAddress?.assumingMemoryBound(to: UInt8.self) + return platform_wallet_create_or_update_dashpay_profile_with_signer( + handle, + idPtr, + namePtr, + msgPtr, + urlPtr, + bytesPtr, + avatarBytes.count, + doCreate, + signerHandle, + &outProfile, + &error + ) + } + } else { + _ = bytes + return platform_wallet_create_or_update_dashpay_profile_with_signer( + handle, + idPtr, + namePtr, + msgPtr, + urlPtr, + nil, + 0, + doCreate, + signerHandle, + &outProfile, + &error + ) + } + } + } + + defer { dashpay_profile_ffi_free(&outProfile) } + + guard result == Success else { + throw PlatformWalletError(result: result, error: error) + } + return DashPayProfile(ffi: outProfile) + }.value + } + /// Shared submit path for create / update — same inputs, same /// error mapping; only the routed FFI function differs. /// @@ -1460,3 +2120,303 @@ private func invokeWithOptionalCStrings( return step1(nil) } } + +// MARK: - Identity transfer / withdraw / update — external-signer wrappers + +/// One recipient row for `transferCreditsToAddresses(...)`. +public struct PlatformAddressCreditOutput: Sendable { + /// Discriminant: `0 = P2PKH`, `1 = P2SH`. Mirrors the Rust-side + /// `PlatformAddress` enum variant tag. + public let addressType: UInt8 + /// 20-byte address hash. + public let hash: Data + /// Credits to transfer to this address. + public let credits: UInt64 + + public init(addressType: UInt8, hash: Data, credits: UInt64) { + self.addressType = addressType + self.hash = hash + self.credits = credits + } +} + +extension ManagedPlatformWallet { + /// Transfer `amount` credits from `fromIdentityId` to + /// `toIdentityId` using the supplied `KeychainSigner` for the + /// identity-state-transition signature. + /// + /// Architecturally aligned with `registerDpnsName(...signer:)` — + /// no signing happens via the wallet's own seed Rust-side, so + /// watch-only wallets work end-to-end on this path. + public func transferCredits( + fromIdentityId: Identifier, + toIdentityId: Identifier, + amount: UInt64, + signer: KeychainSigner + ) async throws { + let handle = self.handle + let signerHandle = signer.handle + let fromBytes: [UInt8] = fromIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let toBytes: [UInt8] = toIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + try await Task.detached(priority: .userInitiated) { + var error = PlatformWalletFFIError() + let result = fromBytes.withUnsafeBufferPointer { fromBp -> PlatformWalletFFIResult in + toBytes.withUnsafeBufferPointer { toBp in + platform_wallet_transfer_credits_with_signer( + handle, + fromBp.baseAddress!, + toBp.baseAddress!, + amount, + signerHandle, + &error + ) + } + } + guard result == Success else { + throw PlatformWalletError(result: result, error: error) + } + }.value + } + + /// Transfer credits from `fromIdentityId` to one or more platform + /// addresses using the supplied `KeychainSigner`. + /// + /// Returns the sender's remaining balance after the transfer. + @discardableResult + public func transferCreditsToAddresses( + fromIdentityId: Identifier, + recipients: [PlatformAddressCreditOutput], + signer: KeychainSigner + ) async throws -> UInt64 { + guard !recipients.isEmpty else { + throw PlatformWalletError.invalidParameter + } + let handle = self.handle + let signerHandle = signer.handle + let fromBytes: [UInt8] = fromIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + + // Materialize recipients into FFI rows. Each `hash` Data must + // contribute a 20-byte tuple to the FFI struct; pad/truncate + // would silently corrupt addresses, so reject on mismatch. + var ffiRows: [PlatformAddressCreditOutputFFI] = [] + ffiRows.reserveCapacity(recipients.count) + for r in recipients { + guard r.hash.count == 20 else { + throw PlatformWalletError.walletOperation( + "PlatformAddressCreditOutput.hash must be exactly 20 bytes (got \(r.hash.count))" + ) + } + // Build a 20-byte tuple from the hash data. + let bytes = [UInt8](r.hash) + let tuple = ( + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], + bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], + bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], + bytes[15], bytes[16], bytes[17], bytes[18], bytes[19] + ) + ffiRows.append( + PlatformAddressCreditOutputFFI( + address_type: r.addressType, + hash: tuple, + credits: r.credits + ) + ) + } + let rows = ffiRows + return try await Task.detached(priority: .userInitiated) { () -> UInt64 in + var newBalance: UInt64 = 0 + var error = PlatformWalletFFIError() + let result = fromBytes.withUnsafeBufferPointer { + fromBp -> PlatformWalletFFIResult in + rows.withUnsafeBufferPointer { rowsBp in + platform_wallet_transfer_credits_to_addresses_with_signer( + handle, + fromBp.baseAddress!, + rowsBp.baseAddress, + rowsBp.count, + signerHandle, + &newBalance, + &error + ) + } + } + guard result == Success else { + throw PlatformWalletError(result: result, error: error) + } + return newBalance + }.value + } + + /// Withdraw `amount` credits from `identityId` to `toAddress` (a + /// network-aware Dash P2PKH address string) using the supplied + /// `KeychainSigner` for the identity-state-transition signature. + public func withdrawCredits( + identityId: Identifier, + amount: UInt64, + toAddress: String, + signer: KeychainSigner + ) async throws { + let handle = self.handle + let signerHandle = signer.handle + let idBytes: [UInt8] = identityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + try await Task.detached(priority: .userInitiated) { + var error = PlatformWalletFFIError() + let result = idBytes.withUnsafeBufferPointer { + idBp -> PlatformWalletFFIResult in + toAddress.withCString { addrPtr in + platform_wallet_withdraw_credits_with_signer( + handle, + idBp.baseAddress!, + amount, + addrPtr, + signerHandle, + &error + ) + } + } + guard result == Success else { + throw PlatformWalletError(result: result, error: error) + } + }.value + } + + /// Update an identity by adding new public keys and/or disabling + /// existing key IDs, signing the resulting + /// `IdentityUpdateTransition` with the identity's MASTER auth key + /// via the supplied `KeychainSigner`. + /// + /// `addPublicKeys` rows MUST already have their matching private + /// material persisted to the signer's store BEFORE calling this — + /// otherwise subsequent operations that try to sign with the + /// newly-added keys will fail. The signer here only signs the + /// update transition itself with an existing MASTER key. + public func updateIdentity( + identityId: Identifier, + addPublicKeys: [ManagedPlatformWallet.IdentityPubkey] = [], + disablePublicKeyIds: [UInt32] = [], + signer: KeychainSigner + ) async throws { + let handle = self.handle + let signerHandle = signer.handle + let idBytes: [UInt8] = identityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let addPubkeys = addPublicKeys + let disableIds = disablePublicKeyIds + try await Task.detached(priority: .userInitiated) { + var error = PlatformWalletFFIError() + // Mirror the registration FFI's pubkey-pinning pattern via + // `withPubkeyFFIArray` so each `pubkey_bytes` pointer the + // FFI sees stays valid for the call duration. + let pubkeyBuffers: [Data] = addPubkeys.map { $0.pubkeyBytes } + let result = idBytes.withUnsafeBufferPointer { + idBp -> PlatformWalletFFIResult in + disableIds.withUnsafeBufferPointer { disableBp in + if addPubkeys.isEmpty { + return platform_wallet_update_identity_with_signer( + handle, + idBp.baseAddress!, + nil, + 0, + disableIds.isEmpty ? nil : disableBp.baseAddress, + disableIds.count, + signerHandle, + &error + ) + } + return ManagedPlatformWallet.withPubkeyFFIArray( + addPubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + platform_wallet_update_identity_with_signer( + handle, + idBp.baseAddress!, + ffiRowsPtr, + ffiRowsCount, + disableIds.isEmpty ? nil : disableBp.baseAddress, + disableIds.count, + signerHandle, + &error + ) + } + } + } + guard result == Success else { + throw PlatformWalletError(result: result, error: error) + } + }.value + } + + /// Register a new asset-lock-funded identity using an external + /// `KeychainSigner`. Asset-lock proof is built Rust-side from + /// `amountDuffs` (wallet must have spendable Core UTXOs). + /// + /// Caller MUST pre-derive `identityPubkeys` (typically via + /// `dash_sdk_derive_identity_keys_from_mnemonic`) AND pre-persist + /// each key's private material to the Keychain using + /// `prePersistIdentityKeysForRegistration` BEFORE calling this — + /// otherwise the IdentityCreate signature can't complete. + /// + /// Returns `(identityId, ManagedIdentity)` for the freshly + /// registered identity. + public func registerIdentityWithFunding( + amountDuffs: UInt64, + identityIndex: UInt32, + identityPubkeys: [ManagedPlatformWallet.IdentityPubkey], + signer: KeychainSigner + ) async throws -> (Identifier, ManagedIdentity) { + guard !identityPubkeys.isEmpty else { + throw PlatformWalletError.invalidParameter + } + let handle = self.handle + let signerHandle = signer.handle + let pubkeys = identityPubkeys + return try await Task.detached(priority: .userInitiated) { + () -> (Identifier, ManagedIdentity) in + var idTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + var outManagedHandle: Handle = NULL_HANDLE + var error = PlatformWalletFFIError() + // Pin each pubkey buffer simultaneously via the existing + // helper, then hand the assembled row array to the FFI. + let pubkeyBuffers: [Data] = pubkeys.map { $0.pubkeyBytes } + let result = ManagedPlatformWallet.withPubkeyFFIArray( + pubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + platform_wallet_register_identity_with_funding_signer( + handle, + amountDuffs, + identityIndex, + ffiRowsPtr, + ffiRowsCount, + signerHandle, + &idTuple, + &outManagedHandle, + &error + ) + } + guard result == Success else { + throw PlatformWalletError(result: result, error: error) + } + // Copy the 32-byte tuple into a Data via withUnsafeBytes. + let identityId = Swift.withUnsafeBytes(of: idTuple) { Data($0) } + return (identityId, ManagedIdentity(handle: outManagedHandle)) + }.value + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swift index 96af03f682c..44ad578f231 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swift @@ -367,6 +367,31 @@ func platform_wallet_update_dashpay_profile( _ out_error: UnsafeMutablePointer ) -> PlatformWalletFFIResult +/// Mirrors `platform_wallet_create_or_update_dashpay_profile_with_signer` +/// from Rust (`packages/rs-platform-wallet-ffi/src/dashpay_profile.rs`). +/// +/// `do_create == true` calls +/// `IdentityWallet::create_profile_with_external_signer`; `false` +/// calls `update_profile_with_external_signer`. Both route the +/// document state-transition signature through the supplied +/// `signer_handle` (typically `KeychainSigner.handle`) instead of an +/// internal `IdentitySigner`. Required for watch-only wallets and +/// the architecturally correct path per `swift-sdk/CLAUDE.md`. +@_silgen_name("platform_wallet_create_or_update_dashpay_profile_with_signer") +func platform_wallet_create_or_update_dashpay_profile_with_signer( + _ wallet_handle: Handle, + _ identity_id: UnsafePointer, + _ display_name: UnsafePointer?, + _ public_message: UnsafePointer?, + _ avatar_url: UnsafePointer?, + _ avatar_bytes: UnsafePointer?, + _ avatar_bytes_len: Int, + _ do_create: Bool, + _ signer_handle: OpaquePointer?, + _ out_profile: UnsafeMutablePointer, + _ out_error: UnsafeMutablePointer +) -> PlatformWalletFFIResult + // MARK: - ContactRequest Functions @_silgen_name("contact_request_create") @@ -528,6 +553,21 @@ func platform_wallet_bytes_free(_ bytes: UnsafeMutablePointer, _ len: Int @_silgen_name("platform_wallet_ffi_error_free") func platform_wallet_ffi_error_free(_ error: UnsafeMutablePointer) +/// hash160 = RIPEMD160(SHA256(data)). 20-byte output. +/// +/// Mirrors `platform_wallet_hash160` from +/// `rs-platform-wallet-ffi/src/utils.rs`. Exposed so the keychain +/// metadata writer can stamp `publicKeyHash` without pulling a +/// RIPEMD-160 implementation into the Swift side (CommonCrypto and +/// CryptoKit don't expose one). Returns 0 on success, -1 on a null / +/// zero-length input. +@_silgen_name("platform_wallet_hash160") +func platform_wallet_hash160( + _ data: UnsafePointer?, + _ data_len: Int, + _ out_hash: UnsafeMutablePointer? +) -> Int32 + // MARK: - DPNS name FFI /// Mirrors `DpnsSearchResultFFI` from @@ -553,6 +593,35 @@ func platform_wallet_register_dpns_name( _ out_error: UnsafeMutablePointer ) -> PlatformWalletFFIResult +/// Mirrors `platform_wallet_register_dpns_name_with_signer` from Rust +/// (`packages/rs-platform-wallet-ffi/src/dpns.rs`). +/// +/// Same as `platform_wallet_register_dpns_name` but signing is routed +/// through the supplied `signer_handle` (typically `KeychainSigner.handle`) +/// instead of through a wallet-derived `IdentitySigner`. Required for +/// watch-only wallets and the path that avoids the inner-lock-deadlock +/// the legacy variant hit when its derivation path tried to +/// `blocking_read` the wallet manager from inside a Tokio worker. +/// +/// The wallet handle is still required so Rust can look up the +/// identity from the in-process `IdentityManager` and pick the +/// HIGH/CRITICAL authentication key DPP requires for document state +/// transitions — but no signing happens via the wallet's own seed. +/// +/// Caller retains ownership of the signer handle. +@_silgen_name("platform_wallet_register_dpns_name_with_signer") +func platform_wallet_register_dpns_name_with_signer( + _ wallet_handle: Handle, + _ identity_id: UnsafePointer, + _ name: UnsafePointer?, + // Raw `*mut SignerHandle` produced by `dash_sdk_signer_create_with_ctx` + // (e.g. via `KeychainSigner.handle`). Used as `Signer`. + // Caller retains ownership; this function does NOT destroy it. + _ signer_handle: OpaquePointer?, + _ out_full_domain_name: UnsafeMutablePointer?>, + _ out_error: UnsafeMutablePointer +) -> PlatformWalletFFIResult + @_silgen_name("platform_wallet_resolve_dpns_name") func platform_wallet_resolve_dpns_name( _ wallet_handle: Handle, @@ -828,6 +897,161 @@ func platform_wallet_send_dashpay_payment( _ out_error: UnsafeMutablePointer ) -> PlatformWalletFFIResult +/// Mirrors `platform_wallet_send_contact_request_with_signer` from +/// Rust. Same shape as `platform_wallet_send_contact_request` but the +/// document state-transition signature is routed through +/// `signer_handle` (typically `KeychainSigner.handle`) instead of an +/// internal `IdentitySigner`. +@_silgen_name("platform_wallet_send_contact_request_with_signer") +func platform_wallet_send_contact_request_with_signer( + _ wallet_handle: Handle, + _ sender_identity_id: UnsafePointer, + _ recipient_identity_id: UnsafePointer, + _ account_label: UnsafePointer?, + _ auto_accept_proof: UnsafePointer?, + _ auto_accept_proof_len: Int, + _ signer_handle: OpaquePointer?, + _ out_request_handle: UnsafeMutablePointer, + _ out_error: UnsafeMutablePointer +) -> PlatformWalletFFIResult + +/// Mirrors `platform_wallet_accept_contact_request_with_signer` from +/// Rust. +@_silgen_name("platform_wallet_accept_contact_request_with_signer") +func platform_wallet_accept_contact_request_with_signer( + _ wallet_handle: Handle, + _ request_handle: Handle, + _ signer_handle: OpaquePointer?, + _ out_established_handle: UnsafeMutablePointer, + _ out_error: UnsafeMutablePointer +) -> PlatformWalletFFIResult + +// MARK: - Identity transfer / withdraw / update — external-signer FFI + +/// Mirrors `PlatformAddressCreditOutputFFI` from +/// `rs-platform-wallet-ffi/src/identity_transfer.rs`. Stripped-down +/// version of `AddressBalanceEntryFFI` — only carries +/// `(address_type, hash, credits)` because the SDK fetches the +/// platform-address nonce at submit time. +@frozen +public struct PlatformAddressCreditOutputFFI { + public var address_type: UInt8 + public var hash: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) + public var credits: UInt64 +} + +/// Mirrors `platform_wallet_transfer_credits_with_signer` from Rust +/// (`packages/rs-platform-wallet-ffi/src/identity_transfer.rs`). +/// +/// Identity → identity credit transfer routed through the supplied +/// `signer_handle` (typically `KeychainSigner.handle`). +@_silgen_name("platform_wallet_transfer_credits_with_signer") +func platform_wallet_transfer_credits_with_signer( + _ wallet_handle: Handle, + _ from_identity_id: UnsafePointer, + _ to_identity_id: UnsafePointer, + _ amount: UInt64, + _ signer_handle: OpaquePointer?, + _ out_error: UnsafeMutablePointer +) -> PlatformWalletFFIResult + +/// Mirrors `platform_wallet_transfer_credits_to_addresses_with_signer` +/// from Rust. Identity → 1+ platform addresses transfer. +/// +/// `out_new_balance` (when non-null) receives the sender's remaining +/// balance after the transfer. +@_silgen_name("platform_wallet_transfer_credits_to_addresses_with_signer") +func platform_wallet_transfer_credits_to_addresses_with_signer( + _ wallet_handle: Handle, + _ from_identity_id: UnsafePointer, + _ outputs: UnsafePointer?, + _ outputs_count: Int, + _ signer_handle: OpaquePointer?, + _ out_new_balance: UnsafeMutablePointer?, + _ out_error: UnsafeMutablePointer +) -> PlatformWalletFFIResult + +/// Mirrors `platform_wallet_withdraw_credits_with_signer` from Rust +/// (`packages/rs-platform-wallet-ffi/src/identity_withdrawal.rs`). +/// +/// `to_address` is a NUL-terminated UTF-8 C-string carrying a +/// network-aware Dash P2PKH address (e.g. `"yNPbcFf..."` for +/// testnet). +@_silgen_name("platform_wallet_withdraw_credits_with_signer") +func platform_wallet_withdraw_credits_with_signer( + _ wallet_handle: Handle, + _ identity_id: UnsafePointer, + _ amount: UInt64, + _ to_address: UnsafePointer?, + _ signer_handle: OpaquePointer?, + _ out_error: UnsafeMutablePointer +) -> PlatformWalletFFIResult + +/// Mirrors `platform_wallet_update_identity_with_signer` from Rust +/// (`packages/rs-platform-wallet-ffi/src/identity_update.rs`). +/// +/// Add and/or disable identity public keys; signs the +/// `IdentityUpdateTransition` with the identity's MASTER auth key +/// via the supplied `signer_handle`. +/// +/// The new keys are passed in as flat `IdentityPubkeyFFI` rows +/// (re-uses the registration-with-signer key-row shape). Caller is +/// responsible for pre-persisting each new key's private material to +/// whatever store the signer reads from (iOS Keychain in the typical +/// case) BEFORE calling this — the signer here only signs the +/// update transition itself with an existing MASTER key. +/// +/// Pass `(nil, 0)` for either array to skip the corresponding +/// operation (e.g. disable-only or add-only updates). +@_silgen_name("platform_wallet_update_identity_with_signer") +func platform_wallet_update_identity_with_signer( + _ wallet_handle: Handle, + _ identity_id: UnsafePointer, + _ add_public_keys: UnsafePointer?, + _ add_public_keys_count: Int, + _ disable_public_key_ids: UnsafePointer?, + _ disable_public_key_ids_count: Int, + _ signer_handle: OpaquePointer?, + _ out_error: UnsafeMutablePointer +) -> PlatformWalletFFIResult + +/// Mirrors `platform_wallet_register_identity_with_funding_signer` +/// from Rust +/// (`packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs`). +/// +/// Asset-lock-funded identity registration driven by an external +/// signer. The asset lock proof is built Rust-side from +/// `amount_duffs` (wallet must have spendable Core UTXOs); the +/// IdentityCreate state transition is signed via `signer_handle`. +/// +/// Caller pre-derives the new identity's authentication pubkeys via +/// `dash_sdk_derive_identity_keys_from_mnemonic` (works for +/// watch-only wallets, unlike the wallet-handle variant) and ships +/// them in via `identity_pubkeys`. Caller is also responsible for +/// pre-persisting each key's matching private material to the +/// signer's store (iOS Keychain in the typical case) BEFORE calling +/// this so the IdentityCreate signature can complete. +@_silgen_name("platform_wallet_register_identity_with_funding_signer") +func platform_wallet_register_identity_with_funding_signer( + _ wallet_handle: Handle, + _ amount_duffs: UInt64, + _ identity_index: UInt32, + _ identity_pubkeys: UnsafePointer?, + _ identity_pubkeys_count: Int, + _ signer_handle: OpaquePointer?, + _ out_identity_id: UnsafeMutablePointer<( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + )>, + _ out_identity_handle: UnsafeMutablePointer, + _ out_error: UnsafeMutablePointer +) -> PlatformWalletFFIResult + // MARK: - Identity persistence FFI /// 32-byte C tuple — mirrors a single `[u8; 32]` on the Rust side. @@ -993,6 +1217,17 @@ struct IdentityKeyPreviewFFI { var public_key: UnsafeMutablePointer? var public_key_len: Int var private_key_wif: UnsafeMutablePointer? + /// Inline 32-byte ECDSA private-key scalar. Mirror of the + /// `[u8; 32]` field on the Rust struct. Treat as sensitive — the + /// Swift caller is expected to copy it straight into the iOS + /// Keychain (via `KeychainManager.storeIdentityPrivateKey`) and + /// drop the local reference. + var private_key_bytes: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) } /// Mirrors `IdentityKeyPreviewsFFI`. `items` points at a contiguous @@ -1031,3 +1266,67 @@ func platform_wallet_preview_identity_registration_keys( func platform_wallet_preview_identity_registration_keys_free( _ previews: UnsafeMutablePointer ) + +// MARK: - Pre-registration key derivation + +/// Mirrors `IdentityRegistrationKeyDerivationsFFI` from +/// `rs-platform-wallet-ffi/src/identity_registration_with_signer.rs`. +/// Same row layout as `IdentityKeyPreviewsFFI` (re-uses +/// `IdentityKeyPreviewFFI`), but each row is one +/// `(identity_index, key_id)` pair fixed to a single identity_index. +struct IdentityRegistrationKeyDerivationsFFI { + var items: UnsafeMutablePointer? + var count: Int +} + +func identityRegistrationKeyDerivationsFFIEmpty() -> IdentityRegistrationKeyDerivationsFFI { + IdentityRegistrationKeyDerivationsFFI(items: nil, count: 0) +} + +/// Derive every authentication-key pair the upcoming +/// `platform_wallet_register_identity_with_signer` call will need +/// for `identity_index`, returning one row per `key_id` in +/// `0..key_count`. +@_silgen_name("platform_wallet_derive_identity_keys_for_index") +func platform_wallet_derive_identity_keys_for_index( + _ wallet_handle: Handle, + _ identity_index: UInt32, + _ key_count: UInt32, + _ out_rows: UnsafeMutablePointer, + _ out_error: UnsafeMutablePointer +) -> PlatformWalletFFIResult + +@_silgen_name("platform_wallet_derive_identity_keys_for_index_free") +func platform_wallet_derive_identity_keys_for_index_free( + _ rows: UnsafeMutablePointer +) + +// MARK: - Mnemonic-driven pre-registration key derivation +// +// Companion entry point to +// `platform_wallet_derive_identity_keys_for_index` that takes the +// BIP-39 mnemonic directly instead of routing through a wallet +// handle. The wallet-handle variant fails for wallets restored from +// SwiftData persistence (the seed lives in iOS Keychain, not in the +// in-process `WalletManager`); this one works for every wallet shape +// because it pulls the seed from the caller per call. +// +// Same row layout as the wallet-handle variant +// (`IdentityRegistrationKeyDerivationsFFI` over `IdentityKeyPreviewFFI`). +// Paired free function frees the matching shape — distinct symbol +// name purely so call sites pair allocator with deallocator 1:1. +@_silgen_name("dash_sdk_derive_identity_keys_from_mnemonic") +func dash_sdk_derive_identity_keys_from_mnemonic( + _ mnemonic_cstr: UnsafePointer, + _ passphrase_cstr: UnsafePointer?, + _ network: DashSDKNetwork, + _ identity_index: UInt32, + _ key_count: UInt32, + _ out_rows: UnsafeMutablePointer, + _ out_error: UnsafeMutablePointer +) -> PlatformWalletFFIResult + +@_silgen_name("dash_sdk_derive_identity_keys_from_mnemonic_free") +func dash_sdk_derive_identity_keys_from_mnemonic_free( + _ rows: UnsafeMutablePointer +) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerFFI.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerFFI.swift index 692d18a8114..6890de6ff8d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerFFI.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerFFI.swift @@ -167,6 +167,34 @@ struct AccountSpecFFI { var account_xpub_bytes_len: Int = 0 } +/// Mirrors `IdentityKeyRestoreFFI` from +/// `rs-platform-wallet-ffi/src/wallet_restore_types.rs`. One entry per +/// public key carried on the load path so the reconstructed +/// `Identity.public_keys` map is populated immediately at cold-start +/// (instead of waiting for the next sync round to refresh it from +/// chain). Field order and types must match bit-for-bit — Rust reads +/// each row as a `#[repr(C)]` struct via `slice::from_raw_parts`. +/// +/// Discriminant conventions match the DPP `repr(u8)` enum layouts and +/// are shared with `IdentityPubkeyFFI` on the registration path: +/// - `key_type`: 0 = ECDSA_SECP256K1, etc. +/// - `purpose`: 0 = AUTHENTICATION, etc. +/// - `security_level`: 0 = MASTER, 1 = CRITICAL, 2 = HIGH, 3 = MEDIUM. +/// +/// `data` is Swift-owned for the duration of the load callback (carries +/// the compressed public-key bytes; 33 for ECDSA_SECP256K1). The +/// matching free callback releases the per-identity arrays plus every +/// `data` byte buffer. +struct IdentityKeyRestoreFFI { + var key_id: UInt32 = 0 + var key_type: UInt8 = 0 + var purpose: UInt8 = 0 + var security_level: UInt8 = 0 + var read_only: Bool = false + var data: UnsafePointer? = nil + var data_len: Int = 0 +} + /// Mirrors `IdentityRestoreEntryFFI` from /// `rs-platform-wallet-ffi/src/wallet_restore_types.rs`. Field order /// and types must match bit-for-bit — Rust reads each entry as a @@ -177,11 +205,12 @@ struct AccountSpecFFI { /// previous `is_watched` discriminant is gone — out-of-wallet /// identities don't ride on this path (no associated wallet). /// -/// All pointer fields (`dpns_names`, `contested_dpns_names`) are -/// Swift-owned during the load-callback window and released by +/// All pointer fields (`dpns_names`, `contested_dpns_names`, `keys`) +/// are Swift-owned during the load-callback window and released by /// `on_load_wallet_list_free_fn`. `dpns_names` / /// `contested_dpns_names` are flat `*const *const c_char` arrays of -/// NUL-terminated UTF-8 strings. +/// NUL-terminated UTF-8 strings; `keys` is a contiguous +/// `[IdentityKeyRestoreFFI]` carrying the per-identity public keys. struct IdentityRestoreEntryFFI { var identity_id: ( UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, @@ -197,6 +226,8 @@ struct IdentityRestoreEntryFFI { var dpns_names_count: Int = 0 var contested_dpns_names: UnsafePointer?>? = nil var contested_dpns_names_count: Int = 0 + var keys: UnsafePointer? = nil + var keys_count: Int = 0 } struct WalletRestoreEntryFFI { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index b2b3c66b79d..03da229a7da 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -821,7 +821,10 @@ public class PlatformWalletPersistenceHandler { keyIndex: indices.keyIndex, derivationPath: derivationPath, publicKey: publicKeyHex, - publicKeyHash: publicKeyHashHex + publicKeyHash: publicKeyHashHex, + keyType: entry.keyType, + purpose: entry.purpose, + securityLevel: entry.securityLevel ) let account = KeychainManager.shared.storeIdentityPrivateKey( privateKey, @@ -1384,10 +1387,6 @@ public class PlatformWalletPersistenceHandler { identities: [PersistentIdentity], allocation: LoadAllocation ) -> UnsafeMutablePointer? { - // `allocation` is no longer needed for label C-strings (the - // label field is gone) — kept for signature stability so the - // calling code site still compiles unchanged. - _ = allocation if identities.isEmpty { return nil } @@ -1418,6 +1417,57 @@ public class PlatformWalletPersistenceHandler { entry.contested_dpns_names = nil entry.contested_dpns_names_count = 0 + // Public keys — read the per-identity `PersistentPublicKey` + // rows (relationship navigated directly; the rows are + // fetched lazily by SwiftData but live in the same + // background context as the identity row so the access is + // synchronous). Sort by `keyId` so the BTreeMap that gets + // built on the Rust side keeps a deterministic order. + let sortedKeys = identity.publicKeys.sorted { $0.keyId < $1.keyId } + if sortedKeys.isEmpty { + entry.keys = nil + entry.keys_count = 0 + } else { + let keyBuf = UnsafeMutablePointer.allocate( + capacity: sortedKeys.count + ) + for (k, pk) in sortedKeys.enumerated() { + var row = IdentityKeyRestoreFFI() + row.key_id = UInt32(bitPattern: pk.keyId) + // PersistentPublicKey stores the discriminants as + // `String(rawValue)` of the original `UInt8` — same + // shape as the `purposeEnum` / `securityLevelEnum` / + // `keyTypeEnum` accessors on the model. Decode + // back to `UInt8`; fall back to 0 (the safest DPP + // default for each enum) on parse failure so we + // don't drop the row entirely. + row.key_type = UInt8(pk.keyType) ?? 0 + row.purpose = UInt8(pk.purpose) ?? 0 + row.security_level = UInt8(pk.securityLevel) ?? 0 + row.read_only = pk.readOnly + + // Allocate a dedicated byte buffer for the public + // key data. Same lifetime convention as xpub + // bytes — released by `LoadAllocation.release` + // via the `scalarBuffers` list. + let len = pk.publicKeyData.count + if len > 0 { + let dataBuf = UnsafeMutablePointer.allocate(capacity: len) + pk.publicKeyData.copyBytes(to: dataBuf, count: len) + row.data = UnsafePointer(dataBuf) + row.data_len = len + allocation.scalarBuffers.append((dataBuf, len)) + } else { + row.data = nil + row.data_len = 0 + } + keyBuf[k] = row + } + entry.keys = UnsafePointer(keyBuf) + entry.keys_count = sortedKeys.count + allocation.identityKeyArrays.append((keyBuf, sortedKeys.count)) + } + buf[j] = entry } allocation.identityArrays.append((buf, identities.count)) @@ -1558,6 +1608,12 @@ private final class LoadAllocation { var addressBalanceArrays: [(UnsafeMutablePointer, Int)] = [] /// `IdentityRestoreEntryFFI` arrays per wallet. var identityArrays: [(UnsafeMutablePointer, Int)] = [] + /// Per-identity `IdentityKeyRestoreFFI` arrays. One entry per + /// identity that has at least one persisted public key. The byte + /// buffers each row's `data` pointer references live in + /// `scalarBuffers` (same `UnsafeMutablePointer.allocate` + /// shape as xpub bytes). + var identityKeyArrays: [(UnsafeMutablePointer, Int)] = [] /// Byte buffers backing `root_xpub_bytes` and `account_xpub_bytes`. var scalarBuffers: [(UnsafeMutablePointer, Int)] = [] /// NUL-terminated c-string buffers carried by identity entries @@ -1587,6 +1643,10 @@ private final class LoadAllocation { ptr.deinitialize(count: count) ptr.deallocate() } + for (ptr, count) in identityKeyArrays { + ptr.deinitialize(count: count) + ptr.deallocate() + } for (ptr, _) in scalarBuffers { ptr.deallocate() } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift index 9f03d941e46..5d9395a11fd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift @@ -384,16 +384,21 @@ public final class KeychainManager: Sendable { return status == errSecSuccess ? identifier : nil } - /// Retrieve data from the keychain by identifier + /// Retrieve data from the keychain by identifier. + /// + /// `nonisolated` so the FFI signer trampoline (which runs from + /// any Tokio worker, off the main actor) can call it directly. + /// `SecItemCopyMatching` is thread-safe and this type's state is + /// `let` — same rationale as `storePrivateKeyNonisolated`. /// - Parameter identifier: The identifier for the stored data /// - Returns: The stored data, or nil if not found - public func retrieveKeyData(identifier: String) -> Data? { + public nonisolated func retrieveKeyData(identifier: String) -> Data? { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: identifier, kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne + kSecMatchLimit as String: kSecMatchLimitOne, ] if let accessGroup = accessGroup { @@ -444,12 +449,58 @@ public final class KeychainManager: Sendable { // MARK: - Identity-key storage (derivation-path keyed) +extension KeychainManager { + /// Compute the 20-byte RIPEMD160(SHA256(pubkey)) hash160 of the + /// public-key bytes, returned as a lowercase hex string. Used to + /// stamp `IdentityPrivateKeyMetadata.publicKeyHash` on identity + /// private-key Keychain rows so the diagnostic explorer can show + /// it without re-deriving. + /// + /// Backed by the Rust `platform_wallet_hash160` FFI helper (the + /// only RIPEMD-160 implementation available without adding a new + /// dependency on the iOS side — neither CryptoKit nor + /// CommonCrypto exposes one). + /// + /// Returns `""` on empty input or FFI failure (matches the + /// pre-change "no hash" sentinel — callers don't have to special- + /// case the empty data path). + public nonisolated static func computePublicKeyHashHex(_ publicKey: Data) -> String { + guard !publicKey.isEmpty else { return "" } + var out = [UInt8](repeating: 0, count: 20) + let rc: Int32 = publicKey.withUnsafeBytes { raw -> Int32 in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return -1 } + return out.withUnsafeMutableBufferPointer { buf -> Int32 in + guard let outBase = buf.baseAddress else { return -1 } + return platform_wallet_hash160(base, publicKey.count, outBase) + } + } + guard rc == 0 else { return "" } + return out.map { String(format: "%02x", $0) }.joined() + } +} + extension KeychainManager { /// Metadata stamped into each identity-private-key keychain item /// as a JSON blob on `kSecAttrGeneric`. Everything here is - /// non-secret — pub-key bytes + hash + derivation breadcrumbs — - /// so the diagnostic keychain explorer can pretty-print it. - public struct IdentityPrivateKeyMetadata: Encodable, Sendable { + /// non-secret — pub-key bytes + hash + DPP key descriptor + + /// derivation breadcrumbs — so the diagnostic keychain explorer + /// can pretty-print it. + /// + /// `keyType` / `purpose` / `securityLevel` use the same DPP + /// `repr(u8)` discriminants every other FFI surface speaks + /// (`IdentityPubkeyFFI`, `IdentityKeyEntryFFI`, + /// `IdentityKeyRestoreFFI`): + /// - `keyType`: 0 = ECDSA_SECP256K1, etc. + /// - `purpose`: 0 = AUTHENTICATION, etc. + /// - `securityLevel`: 0 = MASTER, 1 = CRITICAL, 2 = HIGH, + /// 3 = MEDIUM. + /// + /// **Codable migration**: the three new descriptor fields plus + /// `publicKeyHash` are all optional-decoded (default 0 / empty + /// string) so existing rows written before this struct grew don't + /// fail to decode — the explorer just shows the older shape. + /// New writes always populate everything. + public struct IdentityPrivateKeyMetadata: Codable, Sendable { public let identityId: String // base58 public let keyId: UInt32 public let walletId: String // hex @@ -458,6 +509,9 @@ extension KeychainManager { public let derivationPath: String public let publicKey: String // hex (compressed, 33 bytes typically) public let publicKeyHash: String // hex (20 bytes, RIPEMD160(SHA256)) + public let keyType: UInt8 // DPP KeyType discriminant + public let purpose: UInt8 // DPP Purpose discriminant + public let securityLevel: UInt8 // DPP SecurityLevel discriminant public init( identityId: String, @@ -467,7 +521,10 @@ extension KeychainManager { keyIndex: UInt32, derivationPath: String, publicKey: String, - publicKeyHash: String + publicKeyHash: String, + keyType: UInt8, + purpose: UInt8, + securityLevel: UInt8 ) { self.identityId = identityId self.keyId = keyId @@ -477,6 +534,39 @@ extension KeychainManager { self.derivationPath = derivationPath self.publicKey = publicKey self.publicKeyHash = publicKeyHash + self.keyType = keyType + self.purpose = purpose + self.securityLevel = securityLevel + } + + // Backward-compatible decoding: rows written before the + // descriptor + hash fields existed should still load (the + // explorer just won't have those breadcrumbs to render). + // Default `publicKeyHash` to "" and the three discriminants + // to 0 (the canonical "first" enum value in each DPP enum, + // which happens to be MASTER / AUTHENTICATION / + // ECDSA_SECP256K1 — same as the convention used by the + // registration path for the master key, so it's a sane "I + // don't actually know" fallback). + enum CodingKeys: String, CodingKey { + case identityId, keyId, walletId, identityIndex, keyIndex, + derivationPath, publicKey, publicKeyHash, + keyType, purpose, securityLevel + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.identityId = try c.decode(String.self, forKey: .identityId) + self.keyId = try c.decode(UInt32.self, forKey: .keyId) + self.walletId = try c.decode(String.self, forKey: .walletId) + self.identityIndex = try c.decode(UInt32.self, forKey: .identityIndex) + self.keyIndex = try c.decode(UInt32.self, forKey: .keyIndex) + self.derivationPath = try c.decode(String.self, forKey: .derivationPath) + self.publicKey = try c.decode(String.self, forKey: .publicKey) + self.publicKeyHash = try c.decodeIfPresent(String.self, forKey: .publicKeyHash) ?? "" + self.keyType = try c.decodeIfPresent(UInt8.self, forKey: .keyType) ?? 0 + self.purpose = try c.decodeIfPresent(UInt8.self, forKey: .purpose) ?? 0 + self.securityLevel = try c.decodeIfPresent(UInt8.self, forKey: .securityLevel) ?? 0 } } @@ -532,6 +622,63 @@ extension KeychainManager { return status == errSecSuccess ? account : nil } + /// Look up a stored identity private-key row by its public-key + /// bytes (hex-encoded — matches the `publicKey` field of + /// [`IdentityPrivateKeyMetadata`]). + /// + /// This is the lookup the FFI signer trampoline + /// (`KeychainSigner`) uses when no `PersistentPublicKey` row is + /// available yet — for example mid-identity-registration, where + /// the keys have been pre-derived + persisted to the Keychain by + /// Swift but the SwiftData row is only inserted by the Rust + /// persister callback once registration completes. + /// + /// Scans every `identity_privkey.*` keychain item, parses the + /// metadata blob on `kSecAttrGeneric`, and returns the row whose + /// `publicKey` matches `publicKeyHex`. `nil` if nothing matches. + /// + /// `nonisolated` because Security-framework calls are thread-safe + /// and this type's state is `let` — safe to call from the FFI + /// trampoline on any Tokio worker thread. + public nonisolated func retrieveIdentityPrivateKey(publicKeyHex: String) -> Data? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + ] + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + return nil + } + + let decoder = JSONDecoder() + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + account.hasPrefix("identity_privkey.") + else { + continue + } + guard let metadataData = item[kSecAttrGeneric as String] as? Data, + let metadata = try? decoder.decode(IdentityPrivateKeyMetadata.self, from: metadataData) + else { + continue + } + // Case-insensitive hex compare — both producers downcase + // their hex but be defensive against future writers. + if metadata.publicKey.caseInsensitiveCompare(publicKeyHex) == .orderedSame { + return item[kSecValueData as String] as? Data + } + } + return nil + } + /// Delete the identity private-key row for `derivationPath`. /// Idempotent; returns true on success or "not found". @discardableResult @@ -549,3 +696,17 @@ extension KeychainManager { return status == errSecSuccess || status == errSecItemNotFound } } + +// MARK: - Platform-address private-key storage — REMOVED +// +// Platform-address private keys are NOT persisted. They are pure +// derivation outputs of `(mnemonic, derivation-path)` and exist only +// for the duration of a single signing call. The FFI signer +// trampoline (see `KeychainSigner.swift`'s `key_type == 0xFF` branch) +// pulls the mnemonic from Keychain via `WalletStorage.retrieveMnemonic` +// and the derivation path from `PersistentPlatformAddress`, then +// hands both to `dash_sdk_sign_with_mnemonic_and_path` which derives, +// signs, and zeroes the buffers before returning. +// +// No `platform_address_privkey.*` Keychain account convention exists +// any more. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index 9fca2ddb498..fdce4fa9d7c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -43,7 +43,12 @@ class AppState: ObservableObject { var useLocalPlatform: Bool { useDockerSetup } var useLocalCore: Bool { useDockerSetup } - private let testSigner = TestSigner() + // Identity-key signing is performed per-flow via a fresh + // `KeychainSigner` constructed from the active `ModelContainer` + // (see `CreateIdentityView.submit()`). `AppState` no longer holds + // a long-lived signer field — there is no shared signing state to + // amortize across flows, and the keychain-backed lookup makes + // construction effectively free. private var dataManager: DataManager? private var modelContext: ModelContext? diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 5e96835c674..af52c65c3d7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -203,12 +203,16 @@ class SendViewModel: ObservableObject { // keeps the predicate well-formed when the wallet row // hasn't had its network stamped yet — a wallet in // that state has no identities to find anyway. - let walletNetwork = wallet.network ?? .testnet + // + // Filter against `networkRaw` (the Int-backed shadow + // field) because Foundation's predicate engine can't + // capture `AppNetwork`. + let walletNetworkRaw = (wallet.network ?? .testnet).rawValue let amountThreshold = Int64(bitPattern: amount) let descriptor = FetchDescriptor( predicate: #Predicate { identity in identity.wallet?.walletId == walletId && - identity.network == walletNetwork && + identity.networkRaw == walletNetworkRaw && identity.balance >= amountThreshold } ) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index ce0ba631716..5bd21b611df 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -571,12 +571,14 @@ struct BalanceCardView: View { // `.testnet` is a harmless sentinel for wallets that haven't // had their network stamped yet — they won't have a matching // sync state row either, so the query naturally returns empty. - let walletNetwork = wallet.network ?? .testnet + // Filter against `networkRaw` (the Int-backed shadow field) — + // Foundation's predicate engine can't capture `AppNetwork`. + let walletNetworkRaw = (wallet.network ?? .testnet).rawValue _addressBalances = Query( filter: #Predicate { $0.walletId == walletId } ) _syncStates = Query( - filter: #Predicate { $0.network == walletNetwork } + filter: #Predicate { $0.networkRaw == walletNetworkRaw } ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SDK/SDKExtensions.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SDK/SDKExtensions.swift index be9d60f6ec8..bc0f9d8ca17 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SDK/SDKExtensions.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SDK/SDKExtensions.swift @@ -1,7 +1,9 @@ import Foundation import SwiftDashSDK -// Re-export SDK types for backward compatibility -// The Signer protocol and TestSigner are now in SwiftDashSDK +// Re-export SDK types for backward compatibility. +// +// The `Signer` protocol now lives in SwiftDashSDK. Production +// signing is performed by `KeychainSigner` from SwiftDashSDK; the +// legacy `TestSigner` mock has been removed. public typealias Signer = SwiftDashSDK.Signer -public typealias TestSigner = SwiftDashSDK.TestSigner diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 6c07e325d48..0fb978b902c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -45,6 +45,15 @@ struct CreateIdentityView: View { /// re-fire a SwiftData query. @Query private var allAccounts: [PersistentAccount] + /// All persisted identities. Used by `unusedIdentityIndices(for:)` + /// as the truth source for which identity-registration slots are + /// already taken — the per-`PersistentCoreAddress.isUsed` flag can + /// be stale for *discovered* identities (gap-limit scan finds an + /// existing identity on Platform; the persister callback writes + /// `PersistentIdentity` but doesn't currently flip the matching + /// Core-address `isUsed` flag). + @Query private var allIdentities: [PersistentIdentity] + // MARK: - Selection state /// The source wallet selection. `nil` encodes "pick nothing yet"; @@ -87,14 +96,21 @@ struct CreateIdentityView: View { var body: some View { NavigationStack { Form { - sourceWalletSection - fundingSection - amountSection - identityIndexSection + // Once registration succeeds, collapse the form down to + // just the success banner — the input sections (funding + // source, amount, registration-index stepper with its + // now-irrelevant collision warning) are noise at that + // point. if createdIdentityId != nil { successSection - } else if canSubmit { - submitSection + } else { + sourceWalletSection + fundingSection + amountSection + identityIndexSection + if canSubmit { + submitSection + } } } .navigationTitle("Create Identity") @@ -136,12 +152,13 @@ struct CreateIdentityView: View { // through. fundingSelection = nil walletlessProof = "" - // Default the identity-registration index to the - // first unused slot on the newly-selected wallet, - // or clear it for the walletless / no-selection - // branches. + // Default the identity-registration index to one + // past the highest already-used slot on the newly- + // selected wallet (identities aren't gap-limited; we + // can derive any index N from the mnemonic on demand). + // Walletless / no-selection branches clear it. if case .wallet(let walletId) = newValue { - identityIndex = firstUnusedIdentityIndex(for: walletId) + identityIndex = nextUnusedIdentityIndex(for: walletId) } else { identityIndex = nil } @@ -248,27 +265,38 @@ struct CreateIdentityView: View { // creations don't burn an identity-registration slot off our // HD tree. if case .wallet(let walletId) = walletSelection { - let unused = unusedIdentityIndices(for: walletId) + let used = usedIdentityIndices(for: walletId) + let collision = identityIndex.map { used.contains($0) } ?? false Section { - if unused.isEmpty { - Text("No unused identity registration keys available.") - .font(.subheadline) - .foregroundColor(.secondary) - } else { - Picker("Identity Registration Index", selection: $identityIndex) { - ForEach(unused, id: \.self) { index in - Text("#\(index)") - .tag(Optional(index)) - } + Stepper(value: Binding( + get: { identityIndex ?? 0 }, + set: { identityIndex = $0 } + ), in: 0...UInt32.max) { + HStack { + Text("Identity Registration Index") + Spacer() + Text("#\(identityIndex ?? 0)") + .foregroundColor(collision ? .red : .secondary) + .monospacedDigit() } } + if collision { + Text( + "Index #\(identityIndex ?? 0) is already taken on " + + "this wallet. Pick a different index." + ) + .font(.caption) + .foregroundColor(.red) + } } header: { Text("Identity Registration Index") } footer: { Text( - "The identity-registration key slot the new identity " - + "will consume. Defaults to the lowest unused slot in " - + "the wallet; override to pick any other unused index." + "The DIP-9 identity-registration slot the new identity " + + "will consume " + + "(`m/9'/coin'/5'/0'/0'/N'/0'`). Defaults to one past " + + "the highest index already used on this wallet; " + + "override to pick any other unused index." ) } } @@ -337,8 +365,15 @@ struct CreateIdentityView: View { return !walletlessProof .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty - case (.wallet, .some): - guard identityIndex != nil else { return false } + case (.wallet(let walletId), .some): + guard let identityIndex = identityIndex else { return false } + // Block submit on collision with an existing identity's + // registration index. The picker shows a red collision + // hint, but the button stays disabled regardless so the + // user can't punch through. + if usedIdentityIndices(for: walletId).contains(identityIndex) { + return false + } if let account = selectedPlatformAccount { guard let credits = parsedAmountCredits else { return false } return credits > 0 && credits <= accountBalance(account) @@ -376,16 +411,60 @@ struct CreateIdentityView: View { return } - // Identity registration needs the BIP-39 mnemonic to derive - // DIP-9 auth keys + sign on the Rust side. The wallet struct - // itself is watch-only / external-signable; the secret lives - // in Keychain, keyed by walletId. - let mnemonic: String + // Identity registration goes through the Swift `KeychainSigner` + // now: every state-transition signature crosses the FFI via + // the signer's vtable, so the BIP-39 mnemonic stays in + // Keychain (not handed across the FFI). + // + // We pre-persist ONLY the identity-key private keys, looked + // up by pubkey bytes (`key_type < 5` dispatch). Identity keys + // ARE meant to live in the Keychain as primary storage — + // they are not seed-derived afresh per request, but read + // back from the Keychain by the trampoline. + // + // Platform-address private keys (the `key_type == 0xFF` + // dispatch) are NOT pre-persisted. The trampoline derives + // them on demand per signing call from `(mnemonic, path)` + // via `dash_sdk_sign_with_mnemonic_and_path` and zeroes the + // buffers immediately. The mnemonic stays in Keychain; the + // derivation path lives on `PersistentPlatformAddress`. + let signer = KeychainSigner(modelContainer: modelContext.container) + let walletIdHex = walletId.map { String(format: "%02x", $0) }.joined() + let identityPubkeys: [ManagedPlatformWallet.IdentityPubkey] do { - mnemonic = try WalletStorage().retrieveMnemonic(for: walletId) + // `prePersistIdentityKeysForRegistration` derives every key + // via the watch-only-safe mnemonic FFI and writes the + // private key bytes into Keychain. The returned previews + // already carry the public-key bytes — capture them here + // and thread them through to + // `registerIdentityFromAddresses` so the Rust side does + // NOT re-derive (which would fail for watch-only wallets + // restored from SwiftData state). + // + // Convention for identity-registration auth keys (matches + // the prior Rust-side derivation that lived inside + // `platform_wallet_register_identity_with_signer`): + // - keyId 0 → MASTER, AUTHENTICATION, ECDSA_SECP256K1, !readOnly + // - keyId > 0 → HIGH, AUTHENTICATION, ECDSA_SECP256K1, !readOnly + let previews = try managedWallet.prePersistIdentityKeysForRegistration( + identityIndex: identityIndex, + keyCount: Self.defaultKeyCount, + network: platformState.currentNetwork.sdkNetwork, + walletIdHex: walletIdHex + ) + identityPubkeys = previews.enumerated().map { (i, preview) in + ManagedPlatformWallet.IdentityPubkey( + keyId: UInt32(i), + keyType: .ecdsaSecp256k1, + purpose: .authentication, + securityLevel: i == 0 ? .master : .high, + pubkeyBytes: preview.publicKeyData, + readOnly: false + ) + } } catch { submitError = .init( - message: "Could not read the wallet's mnemonic from Keychain: \(error.localizedDescription)" + message: "Could not pre-derive identity keys: \(error.localizedDescription)" ) return } @@ -412,9 +491,9 @@ struct CreateIdentityView: View { inputs: inputs, output: nil, identityIndex: identityIndex, - keyCount: Self.defaultKeyCount, - mnemonic: mnemonic, - passphrase: nil + identityPubkeys: identityPubkeys, + identitySigner: signer, + addressSigner: signer ) try await MainActor.run { @@ -653,20 +732,34 @@ struct CreateIdentityView: View { /// registration slot keyed by `addressIndex`; `isUsed` flips to /// true once the slot has been consumed by a prior identity /// creation. Returns an ascending list of the remaining slots. - private func unusedIdentityIndices(for walletId: Data) -> [UInt32] { - guard let account = identityRegistrationAccount(for: walletId) else { - return [] - } - return account.coreAddresses - .filter { !$0.isUsed } - .map { $0.addressIndex } - .sorted() + /// Identity-registration indices currently claimed by an existing + /// `PersistentIdentity` on this wallet. Single source of truth — + /// the deprecated `PersistentCoreAddress.isUsed` flag was a + /// denormalized cache that drifted (discovered identities never + /// flipped it). + private func usedIdentityIndices(for walletId: Data) -> Set { + Set( + allIdentities + .filter { $0.wallet?.walletId == walletId } + .map { $0.identityIndex } + ) } - /// Lowest unused identity-registration index on a wallet, or - /// `nil` if no slots remain. Drives the picker's default value. - private func firstUnusedIdentityIndex(for walletId: Data) -> UInt32? { - unusedIdentityIndices(for: walletId).first + /// One past the highest used registration index on this wallet, + /// or `0` for a wallet that has never registered an identity. + /// Identity-registration keys aren't gap-limited — any `N` is + /// derivable from the mnemonic at `m/9'/coin'/5'/0'/0'/N'/0'` — + /// so "next unused" is just `max + 1`. + /// + /// Uses checked addition: at `UInt32.max` we leave the picker at + /// `UInt32.max` rather than wrapping to `0` (which would silently + /// pre-fill a colliding slot). The user can still adjust the + /// stepper, and `canSubmit` keeps the button disabled until the + /// chosen index isn't already taken. + private func nextUnusedIdentityIndex(for walletId: Data) -> UInt32 { + let used = usedIdentityIndices(for: walletId) + guard let highest = used.max() else { return 0 } + return highest == UInt32.max ? UInt32.max : highest + 1 } private func identityRegistrationAccount(for walletId: Data) -> PersistentAccount? { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift index 201c6b53239..c53250b41e3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift @@ -484,6 +484,7 @@ struct AddFriendView: View { @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext @State private var searchText = "" @State private var searchMethod = 0 // 0: DPNS, 1: Identity ID @State private var isSending = false @@ -595,9 +596,18 @@ struct AddFriendView: View { recipientId = parsed } + // Construct a fresh `KeychainSigner` and route through + // the platform-wallet + // `IdentityWallet::send_contact_request_with_external_signer` + // path. CAVEAT: the contact-request encryption step + // still derives the sender's ECDH key Rust-side from + // the wallet seed (watch-only wallets fail there) — + // see the docstring on `sendContactRequest(...,signer:)`. + let signer = KeychainSigner(modelContainer: modelContext.container) _ = try await wallet.sendContactRequest( senderIdentityId: identity.identityId, - recipientIdentityId: recipientId + recipientIdentityId: recipientId, + signer: signer ) onSent() dismiss() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index e1c92915651..687156aa17c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -253,8 +253,17 @@ struct IdentityDetailView: View { EditAliasView(identity: identity, newAlias: $newAlias) } .sheet(isPresented: $showingRegisterName) { - RegisterNameView(identity: identity) - .environmentObject(appState) + RegisterNameView(identity: identity, onRegistered: { name in + // Append the just-registered name to the local @State + // list immediately so the section re-renders without + // waiting for the parent's `onAppear` re-fetch. + // De-dupe in case a stale `loadDPNSNames()` already + // landed it. + if !dpnsNames.contains(name) { + dpnsNames.append(name) + } + }) + .environmentObject(appState) } .sheet(isPresented: $showingSelectMainName) { SelectMainNameView(identity: identity) @@ -772,6 +781,7 @@ struct DashPayProfileEditorView: View { @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.dismiss) var dismiss + @Environment(\.modelContext) private var modelContext @State private var displayName: String = "" @State private var publicMessage: String = "" @@ -895,16 +905,27 @@ struct DashPayProfileEditorView: View { errorMessage = "No wallet available for this identity" return } + // Construct a fresh `KeychainSigner` for this submit + // pass the same way `RegisterNameView.registerName()` + // does. Routes the document state-transition signature + // through the iOS Keychain instead of the legacy + // wallet-internal `IdentitySigner`, so watch-only + // wallets are unblocked end-to-end and the Tokio + // worker no longer risks the inner-lock-deadlock the + // legacy path hit. + let signer = KeychainSigner(modelContainer: modelContext.container) let saved: DashPayProfile if isCreating { saved = try await wallet.createDashPayProfile( identityId: identityId, - update: update + update: update, + signer: signer ) } else { saved = try await wallet.updateDashPayProfile( identityId: identityId, - update: update + update: update, + signer: signer ) } onSaved(saved) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegisterNameView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegisterNameView.swift index 43df8a1570b..2f1657e064a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegisterNameView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegisterNameView.swift @@ -1,11 +1,23 @@ import SwiftUI import SwiftDashSDK +import SwiftData struct RegisterNameView: View { let identity: PersistentIdentity + /// Optional success callback invoked on the main actor right after + /// the FFI call returns the registered full domain name. The parent + /// uses this to refresh its own DPNS-names state — `IdentityDetailView` + /// holds the list in `@State` (not `@Query`-bound), so the sheet + /// dismissing alone is not enough to trigger a re-fetch. + var onRegistered: ((String) -> Void)? = nil @EnvironmentObject var appState: AppState @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.dismiss) var dismiss + // Required by `KeychainSigner` so its trampoline can fetch + // `PersistentPublicKey` rows on a private background context. The + // view doesn't query SwiftData itself — `modelContext.container` + // is the only thing it threads through to the signer. + @Environment(\.modelContext) private var modelContext @State private var username = "" @State private var isChecking = false @@ -367,17 +379,20 @@ struct RegisterNameView: View { } private func registerName() { - // Route through the platform-wallet `IdentityWallet::register_name` - // path instead of `dash_sdk_dpns_register_name`. Benefits: - // - Signing key resolution happens in Rust against the - // identity's `key_storage`, so Swift doesn't have to build - // identity/pubkey/signer FFI handles by hand. + // Route through the platform-wallet + // `IdentityWallet::register_name_with_external_signer` path + // (via `registerDpnsName(identityId:name:signer:)`). Benefits: + // - Signing crosses the FFI through the Swift `KeychainSigner`, + // same model identity registration uses — so the wallet's + // own seed never participates and watch-only restored + // wallets can register names too. + // - Avoids the legacy `IdentitySigner` path, which would + // deadlock the Tokio worker by `blocking_read`-ing the + // wallet manager from inside the signing future. // - On success the Rust side appends the new name to // `ManagedIdentity.dpns_names` and queues an `IdentityChangeSet` // so `PersistentIdentity.dpnsName` refreshes automatically via // the persister callback — no manual SwiftData upsert needed. - // - Removes ~100 lines of FFI plumbing that previously had to - // mirror `KeyManager` + `dash_sdk_identity_create_from_components`. guard let walletId = identity.wallet?.walletId, let wallet = walletManager.wallet(for: walletId) else { errorMessage = "No wallet available for this identity" @@ -385,22 +400,42 @@ struct RegisterNameView: View { return } + // Construct a fresh `KeychainSigner` for this registration the + // same way `CreateIdentityView.submit()` does. The signer holds + // a `+1`-retained C-side handle that's released in `deinit`; + // keeping it in a local across the awaited Task is enough to + // pin its lifetime past the FFI calls. The trampoline performs + // the `PersistentPublicKey` → Keychain lookup on a private + // background `ModelContext` derived from + // `modelContext.container`. + let signer = KeychainSigner(modelContainer: modelContext.container) + isRegistering = true Task { do { let registeredName = try await wallet.registerDpnsName( identityId: identity.identityId, - name: normalizedUsername + name: normalizedUsername, + signer: signer ) await MainActor.run { - // The Rust-side `IdentityWallet::register_name` already - // appended the new label to `ManagedIdentity.dpns_names` - // and emitted an `IdentityChangeSet` — the Swift persister - // callback wrote the update to `PersistentIdentity`, and - // every view that uses `@Query` picks up the change - // automatically. No in-memory mirror needed. + // Rust persister callback writes the new label to + // `PersistentIdentity` asynchronously, but the parent + // (`IdentityDetailView`) caches the list in plain `@State` + // populated only at `onAppear` — so `@Query` reactivity + // doesn't help here. Fire `onRegistered` so the parent + // can immediately add this name to its local state and + // skip the "wait until I leave + come back" round-trip. + // + // Pass the bare label (`normalizedUsername`), NOT the FFI's + // full-domain return value (`registeredName` = "name.dash"). + // The parent's `dpnsNames` array stores bare labels — that's + // what `managed.getDpnsNames()` returns — so passing the + // full domain here would render "trym0re2.dash" instead of + // "trym0re2" until the next `loadDPNSNames` round. + onRegistered?(normalizedUsername) registrationSuccess = true errorMessage = isContested ? diff --git a/scripts/check-storage-explorer.sh b/scripts/check-storage-explorer.sh index 3b92d7b906b..55e150dc9d3 100755 --- a/scripts/check-storage-explorer.sh +++ b/scripts/check-storage-explorer.sh @@ -18,7 +18,14 @@ errors=0 # Extract model type names from DashModelContainer.modelTypes array. # Matches lines like "PersistentFoo.self," and extracts "PersistentFoo". -model_types=$(grep -oE '[A-Z][A-Za-z]+\.self' "$CONTAINER" | sed 's/\.self//' | sort) +# Scoped to the body of the `modelTypes` computed property so other +# `.self` references in the file (e.g. `migrationPlan: +# DashMigrationPlan.self` passed to ModelContainer) aren't mistaken +# for SwiftData models. +model_types=$(awk '/var modelTypes/{flag=1} flag{print} flag && /^ \}/{flag=0}' "$CONTAINER" \ + | grep -oE '[A-Z][A-Za-z0-9]+\.self' \ + | sed 's/\.self//' \ + | sort -u) if [ -z "$model_types" ]; then echo "ERROR: Could not extract any model types from DashModelContainer.swift"