diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index b34bb150a6c..397aac4e358 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -185,7 +185,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature. Today only `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build, returns it, for a request that came privately: its private reply is paid from the pool. Nothing was sent; a public or adopted reply works on such a build. Not retryable on the same build. No existing code meant "shielded unsupported" (`ErrorWalletOperation` with a not-configured message is a runtime state of a shielded build), so it takes the next free number. Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.ShieldedBuildRequired`, `DashSdkErrorTest` pin) type it | | 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`), because the network is proven at a protocol version below the one that serves it or the SDK is pinned below it: for example the private contact request sync on a network before protocol version 15. Expected on such a network, so a host may stay quiet about it; it lifts when the network upgrades. Mapped by the blanket conversion for every operation that propagates the SDK error. A refusal that may lift sooner (`is_transient_protocol_version_refusal`: the network's version could not be confirmed) keeps the catch-all `ErrorUnknown`, as before. Previously both fell to `ErrorUnknown`. Swift (`PlatformWalletError.protocolVersionNotActive`) and Kotlin (`DashSdkError.PlatformWallet.ProtocolVersionNotActive`) type it | | 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRequestPending`: a private contact request to that identity is in flight (another private send or reply reserved it before sealing), or was paid for and a short read-back sync did not find the entry in the store yet; or the identity's private store could not be read to its end before its first private send or accept since load, which the wallet reads first so that a reply paid for in an earlier process is adopted rather than paid for again. Nothing new was paid; a later private contact request sync, or a retry, settles it. Pending, not failed: the host must not submit it again. Returned by the private send, the private accept and the in-kind accept; the shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Swift (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.PrivateContactRequestPending`, `DashSdkErrorTest` pin) type it, and both example apps keep the submission pending | -| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet was loaded after binding a shielded account in an earlier process, or the identity was loaded with its private scan state marking a private payment that may be unresolved, so its unconfirmed private payments may hold pairs no reservation shows yet. The public send, the public accept and the in-kind accept are refused with nothing signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does; a private sync does not, and a build without the `shielded` feature never does. `platform_wallet_get_private_contact_readiness` reads the state, so a host shows it rather than inferring it. Swift and Kotlin must type it, as for 71, and treat it as held until the readiness reads restored | +| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet was loaded after binding a shielded account in an earlier process, or the identity was loaded with its private scan state marking a private payment that may be unresolved, so its unconfirmed private payments may hold pairs no reservation shows yet. The public send, the public accept and the in-kind accept are refused with nothing signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does; a private sync does not, and a build without the `shielded` feature never does. `platform_wallet_get_private_contact_readiness` reads the state, so a host shows it rather than inferring it. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds the readiness (`ManagedPlatformWallet.privateContactReadiness`); SwiftExampleApp shows "restoring private payments" apart from a pending request (71) until the readiness reads restored. Kotlin must type it, as for 71 | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs index 416e82870dc..2c23c31439d 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs @@ -214,6 +214,51 @@ pub struct ContactIgnoredSenderFFI { pub is_ignored: bool, } +/// One identity's read position in DashPay's private contact request store +/// ([`ContactChangeSet::private_contact_requests_positions`]), for the +/// private contact request scan state callbacks on +/// [`PersistenceCallbacksExtension`](crate::persistence::PersistenceCallbacksExtension). +/// +/// Flat POD. The host stores one row per `(wallet_id, identity_id)`, +/// replacing the previous one, and hands it back unchanged at load: a lower +/// `position` than the stored one is a rewind, not a stale write. +/// +/// [`ContactChangeSet::private_contact_requests_positions`]: platform_wallet::changeset::ContactChangeSet::private_contact_requests_positions +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PrivateContactRequestsPositionFFI { + /// The wallet-owned identity reading the store. + pub identity_id: [u8; 32], + /// The store position the identity reads from next. + pub position: u64, + /// The fingerprint of the scanning keys `position` holds for. Opaque + /// to the host: store it and return it as it came. + pub key_fingerprint: [u8; 32], + /// Whether a private contact request the identity paid for may still + /// be unresolved. Store it and return it as it came: loaded set, it + /// holds the identity's public contact requests until its private + /// payments are restored from the shielded store. + pub private_payments_unresolved: bool, +} + +/// The shielded address `contact_id`'s tracked private request to +/// `owner_id` carries ([`ContactChangeSet::private_shielded_addresses`]). +/// +/// Flat POD. The host stores one row per `(wallet_id, owner_id, +/// contact_id)`, replacing the previous one. +/// +/// [`ContactChangeSet::private_shielded_addresses`]: platform_wallet::changeset::ContactChangeSet::private_shielded_addresses +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PrivateShieldedAddressFFI { + /// The wallet-owned identity the request is to. + pub owner_id: [u8; 32], + /// The contact who sent the request. + pub contact_id: [u8; 32], + /// The raw 43-byte Orchard payment address. + pub address: [u8; 43], +} + // Compile-time guards. Pin the expected layouts so any reshape on // the Rust side fails the cargo build before it can ship a dylib // the Swift side will mis-parse at runtime. @@ -268,6 +313,39 @@ const _: [u8; 1] = [0u8; std::mem::align_of::()]; const _: [u8; 65] = [0u8; std::mem::size_of::()]; const _: [u8; 1] = [0u8; std::mem::align_of::()]; +// Expected `PrivateContactRequestsPositionFFI` layout on 64-bit targets: +// +// 0..=31 identity_id [u8; 32] +// 32..=39 position u64 +// 40..=71 key_fingerprint [u8; 32] +// 72 private_payments_unresolved bool +// 73..=79 (tail padding to 8) +// +// Total size = 80, alignment = 8 (from the u64). +const _: [u8; 80] = [0u8; std::mem::size_of::()]; +const _: [u8; 8] = [0u8; std::mem::align_of::()]; +const _: () = assert!(std::mem::offset_of!(PrivateContactRequestsPositionFFI, position) == 32); +const _: () = + assert!(std::mem::offset_of!(PrivateContactRequestsPositionFFI, key_fingerprint) == 40); +const _: () = assert!( + std::mem::offset_of!( + PrivateContactRequestsPositionFFI, + private_payments_unresolved + ) == 72 +); + +// Expected `PrivateShieldedAddressFFI` layout on all targets: +// +// 0..=31 owner_id [u8; 32] +// 32..=63 contact_id [u8; 32] +// 64..=106 address [u8; 43] +// +// Total size = 107, alignment = 1 (all-byte fields). +const _: [u8; 107] = [0u8; std::mem::size_of::()]; +const _: [u8; 1] = [0u8; std::mem::align_of::()]; +const _: () = assert!(std::mem::offset_of!(PrivateShieldedAddressFFI, contact_id) == 32); +const _: () = assert!(std::mem::offset_of!(PrivateShieldedAddressFFI, address) == 64); + impl ContactIgnoredSenderFFI { /// Project an `(owner, sender)` ignore key onto its flat C mirror. /// `is_ignored` distinguishes an ignore (persist the row) from an diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index fc04b02c647..72c795781f2 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -190,7 +190,7 @@ impl ContactRequestHandleArray { } /// Copy a slice of contact requests into the global handle storage. - fn from_requests(requests: Vec) -> Self { + pub(crate) fn from_requests(requests: Vec) -> Self { if requests.is_empty() { return Self::empty(); } @@ -325,22 +325,9 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( } else { Some(unwrap_result_or_return!(CStr::from_ptr(account_label).to_str()).to_string()) }; - let proof: Option> = if auto_accept_proof.is_null() || auto_accept_proof_len == 0 { - None - } else { - // Defense-in-depth: bound the copy at the DIP-15 auto-accept proof - // ceiling (102 bytes) BEFORE allocating, so a malformed binding or a - // hostile (ptr, len) pair can't force an oversized allocation. The SDK - // re-validates the exact 38..=102 range after this. - if auto_accept_proof_len > 102 { - return crate::error::PlatformWalletFFIResult::err( - crate::error::PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!( - "auto_accept_proof length {auto_accept_proof_len} exceeds the DIP-15 maximum of 102 bytes" - ), - ); - } - Some(std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec()) + let proof = match read_auto_accept_proof(auto_accept_proof, auto_accept_proof_len) { + Ok(proof) => proof, + Err(result) => return result, }; let signer_addr = signer_handle as usize; @@ -380,6 +367,31 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( PlatformWalletFFIResult::ok() } +/// Copy an optional auto-accept proof from `(ptr, len)`; `(null, 0)` omits it. +/// +/// Defense-in-depth: the copy is bounded at the DIP-15 auto-accept proof +/// ceiling (102 bytes) BEFORE allocating, so a malformed binding or a hostile +/// (ptr, len) pair can't force an oversized allocation. The SDK re-validates +/// the exact 38..=102 range after this. +/// +/// # Safety +/// A non-null `ptr` must point to `len` readable bytes. +pub(crate) unsafe fn read_auto_accept_proof( + ptr: *const u8, + len: usize, +) -> Result>, PlatformWalletFFIResult> { + if ptr.is_null() || len == 0 { + return Ok(None); + } + if len > 102 { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("auto_accept_proof length {len} exceeds the DIP-15 maximum of 102 bytes"), + )); + } + Ok(Some(std::slice::from_raw_parts(ptr, len).to_vec())) +} + /// Send a contact request from a scanned DIP-15 auto-accept QR /// (`dash:?du=&dapk=`). Resolves the QR's username to the /// owner's identity, decodes the handed auto-accept key, signs the proof over @@ -1755,6 +1767,84 @@ pub unsafe extern "C" fn platform_wallet_drainable_contact_crypto_count( mod tests { use super::*; + mod private_store_scan_errors { + use super::*; + use platform_wallet::ContactCryptoProvider; + use rs_sdk_ffi::{ + dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy, + mnemonic_resolver_result, + }; + use std::ffi::c_void; + use std::str::FromStr; + + unsafe extern "C" fn no_mnemonic( + _ctx: *const c_void, + _wallet_id_bytes: *const u8, + _out_buf: *mut c_char, + _out_capacity: usize, + _out_len: *mut usize, + ) -> i32 { + mnemonic_resolver_result::NOT_FOUND + } + + unsafe extern "C" fn noop_destroy(_ctx: *mut c_void) {} + + /// A scan of the private store whose resolver holds no mnemonic for the + /// wallet reaches the host as the key-unavailable outcome (code 31), as + /// a signer's missing key does, so the host routes it to key repair + /// instead of reading a generic failure. + #[test] + fn should_report_a_missing_mnemonic_while_scanning_as_key_unavailable() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), no_mnemonic, noop_destroy) + }; + let provider = unsafe { + resolver_contact_crypto_provider(resolver, [0u8; 32], key_wallet::Network::Testnet) + }; + let keys = PrivateDocumentKeyPaths { + recipients: vec![( + Identifier::from([0xB0; 32]), + vec![( + 2, + key_wallet::bip32::DerivationPath::from_str("m/9'/1'/5'/0'/0'/1'/2'") + .expect("valid path"), + )], + )], + senders: Vec::new(), + }; + let page = PrivateDocumentEntries { + chunk_power: None, + entries: Vec::new(), + total_count: 0, + }; + let error = runtime() + .block_on(provider.scan_private_document_page( + &keys, + Identifier::from([0xD0; 32]), + "privateContactRequest", + 0, + &page, + )) + .expect_err("no mnemonic, no keys"); + let result = PlatformWalletFFIResult::from(error); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + ); + + // Another resolver failure keeps the code it had. + let other = PlatformWalletFFIResult::from(private_document_signer_error( + MnemonicResolverSignerError::BufferTooSmall, + )); + assert_ne!( + other.code, + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + } + /// A test wallet's mnemonic, counting how often the resolver reads it. unsafe extern "C" fn counting_resolve( ctx: *const std::ffi::c_void, diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 4734b414279..2e50b07c8e7 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -71,6 +71,7 @@ pub mod platform_address_sync; pub mod platform_address_types; pub mod platform_addresses; pub mod platform_wallet_info; +pub mod private_contact_request; pub mod provider_key_at_index; mod runtime; pub mod secp256k1_primitives; @@ -156,6 +157,7 @@ pub use platform_address_sync::*; pub use platform_address_types::*; pub use platform_addresses::*; pub use platform_wallet_info::*; +pub use private_contact_request::*; pub use provider_key_at_index::*; pub use secp256k1_primitives::*; #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index dd0675fca9f..b93dda6a0b7 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,12 +8,14 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, - LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, - PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, - PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, - PersistWalletChangesetUtxoVerdictsFn, PersistWalletDashPayBackfillFn, PersistenceCallbacks, - PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + FFIPersister, FreePrivateContactScanStateFn, FreeTrackedMasternodesFn, + LoadAssetLockOperationIdFn, LoadIdentityBalanceBlockTimeFn, LoadPrivateContactScanStateFn, + LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistDpnsNameStatesFn, + PersistIdentityBalanceBlockTimeFn, PersistPrivateContactScanStateFn, + PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, + PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, + PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; @@ -22,6 +24,7 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; use dash_sdk::Sdk; use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use platform_wallet::changeset::PlatformWalletPersistence; use platform_wallet::PlatformWalletManager; use platform_wallet::{PersistenceCapabilities, PERSISTENCE_CAPABILITIES_VERSION}; use std::os::raw::c_void; @@ -175,6 +178,32 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( ) } +/// The persistence capabilities a manager created from these callbacks +/// would attest, without creating one: the declaration intersected with what +/// the vtable and the extension structurally carry, as +/// [`platform_wallet_manager_create_with_extensions`] computes them. For a +/// host adapter's own tests, such as the JNI layer checking which bridge +/// overrides keep a capability. `persistence` is consumed: its `release_fn` +/// runs before this returns. +/// +/// # Safety +/// As for [`platform_wallet_manager_create_with_extensions`]: `extension` +/// must point to a readable [`PersistenceCallbacksExtension`] of at least +/// its declared `struct_size`, and `persistence`'s context must be valid +/// for its `release_fn`. +pub unsafe fn effective_persistence_capabilities( + persistence: PersistenceCallbacks, + declared: &PersistenceCapabilitiesFFI, + extension: *const PersistenceCallbacksExtension, +) -> PersistenceCapabilities { + FFIPersister::new_with_persistence_capabilities_and_extensions( + persistence, + persistence_capabilities_declaration(declared), + persistence_extension_callbacks(extension), + ) + .persistence_capabilities() +} + /// Read one negotiated slot out of a size/version-tagged extension struct /// — the single authority for the gate every reader below applies. A slot /// is read only when the host's declared `struct_size` proves it was @@ -290,6 +319,18 @@ unsafe fn persistence_extension_callbacks( on_load_asset_lock_operation_id_fn, LoadAssetLockOperationIdFn ), + persist_private_contact_scan_state: slot!( + on_persist_private_contact_scan_state_fn, + PersistPrivateContactScanStateFn + ), + load_private_contact_scan_state: slot!( + on_load_private_contact_scan_state_fn, + LoadPrivateContactScanStateFn + ), + load_private_contact_scan_state_free: slot!( + on_load_private_contact_scan_state_free_fn, + FreePrivateContactScanStateFn + ), } } @@ -867,6 +908,9 @@ pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( #[cfg(test)] mod tests { use super::*; + use crate::contact_persistence::{ + ContactRequestRemovalFFI, PrivateContactRequestsPositionFFI, PrivateShieldedAddressFFI, + }; unsafe extern "C" fn begin_changeset(_context: *mut c_void, _wallet_id: *const u8) -> i32 { 0 @@ -1372,6 +1416,106 @@ mod tests { assert!(parsed.load_identity_balance_block_time.is_none()); } + /// The effective capabilities are the declaration intersected with what + /// the callbacks carry, and the vtable's `release_fn` runs once. + #[test] + fn should_compute_the_effective_persistence_capabilities_without_a_manager() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static RELEASES: AtomicUsize = AtomicUsize::new(0); + unsafe extern "C" fn release(_: *mut c_void) { + RELEASES.fetch_add(1, Ordering::SeqCst); + } + let declared = PersistenceCapabilitiesFFI { + version: PERSISTENCE_CAPABILITIES_VERSION, + reserved: 0, + bits: PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE) + .bits(), + }; + let extension = PersistenceCallbacksExtension::default(); + let effective = unsafe { + effective_persistence_capabilities( + PersistenceCallbacks { + context: std::ptr::NonNull::::dangling().as_ptr().cast(), + release_fn: Some(release), + on_changeset_begin_fn: Some(begin_changeset), + on_changeset_end_fn: Some(end_changeset), + ..PersistenceCallbacks::default() + }, + &declared, + &extension, + ) + }; + assert!(effective.contains(PersistenceCapabilities::ATOMIC_CHANGESETS)); + assert!( + !effective.contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE), + "declared without the callbacks it needs" + ); + assert_eq!(RELEASES.load(Ordering::SeqCst), 1); + } + + #[test] + fn should_size_gate_private_contact_scan_state_slots_independently() { + #[allow(clippy::too_many_arguments)] + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const PrivateContactRequestsPositionFFI, + _: usize, + _: *const PrivateShieldedAddressFFI, + _: usize, + _: *const ContactRequestRemovalFFI, + _: usize, + _: *const [u8; 32], + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *mut *const PrivateContactRequestsPositionFFI, + _: *mut usize, + _: *mut *const PrivateShieldedAddressFFI, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free( + _: *mut c_void, + _: *const PrivateContactRequestsPositionFFI, + _: usize, + _: *const PrivateShieldedAddressFFI, + _: usize, + ) { + } + let mut ext = PersistenceCallbacksExtension { + on_persist_private_contact_scan_state_fn: Some(persist), + on_load_private_contact_scan_state_fn: Some(load), + on_load_private_contact_scan_state_free_fn: Some(free), + ..Default::default() + }; + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_private_contact_scan_state.is_some()); + assert!(parsed.load_private_contact_scan_state.is_some()); + assert!(parsed.load_private_contact_scan_state_free.is_some()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_free_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.load_private_contact_scan_state.is_some()); + assert!(parsed.load_private_contact_scan_state_free.is_none()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_private_contact_scan_state_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_private_contact_scan_state.is_none()); + assert!(parsed.load_private_contact_scan_state.is_none()); + assert!(parsed.load_private_contact_scan_state_free.is_none()); + } + #[test] fn should_size_gate_asset_lock_operation_id_slots_independently() { unsafe extern "C" fn persist( diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index eb5865fc361..aa70789d325 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -32,9 +32,10 @@ use platform_wallet::changeset::provider_key_account::{ }; use platform_wallet::changeset::{ AccountAddressPoolEntry, AccountRegistrationEntry, ClientStartState, ClientWalletStartState, - ListedCoreTxid, PersistenceCapabilities, PersistenceError, PersistenceErrorKind, - PlatformWalletChangeSet, PlatformWalletPersistence, ProviderKeyAccountEntry, - ProviderKeyExtendedPubKey, PERSISTENCE_CAPABILITIES_VERSION, + ContactChangeSet, ListedCoreTxid, PersistenceCapabilities, PersistenceError, + PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, + PrivateContactRequestsPosition, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, + PERSISTENCE_CAPABILITIES_VERSION, }; use platform_wallet::wallet::asset_lock::restored_record::held_output_details; use platform_wallet::wallet::core::stored_coin::{reconcile_with_parent, StoredParentState}; @@ -53,7 +54,8 @@ use crate::asset_lock_persistence::{ build_asset_lock_entries, outpoint_to_bytes, AssetLockEntryFFI, }; use crate::contact_persistence::{ - free_contact_requests_ffi, ContactIgnoredSenderFFI, ContactRequestFFI, ContactRequestRemovalFFI, + free_contact_requests_ffi, ContactIgnoredSenderFFI, ContactRequestFFI, + ContactRequestRemovalFFI, PrivateContactRequestsPositionFFI, PrivateShieldedAddressFFI, }; use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{ @@ -161,6 +163,15 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES: u64 = 1 << pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << 11; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS: u64 = 1 << 12; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = 1 << 13; +/// The private contact request sync's scan state (each identity's read +/// position and its private contacts' shielded addresses) is persisted AND +/// restored across restarts. Requires the extension trio +/// `on_persist_private_contact_scan_state_fn` + +/// `on_load_private_contact_scan_state_fn` + +/// `on_load_private_contact_scan_state_free_fn`, the wallet-list restore +/// callbacks, `on_persist_contacts_fn` (a read position vouches for the +/// contact requests read before it), and the host declaring the bit. +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: u64 = 1 << 14; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -323,6 +334,57 @@ pub type LoadAssetLockOperationIdFn = unsafe extern "C" fn( out_operation_id: *mut u8, ) -> i32; +/// Store a round's private contact request scan state for `wallet_id` +/// (see [`PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE`]), +/// inside the round's begin/end bracket, after the contacts callback: +/// +/// - `positions`: replace the identity's stored read position. +/// - `addresses`: replace the stored address for `(owner_id, contact_id)`. +/// - `removed_addresses`: delete the stored address for `(owner_id, +/// contact_id)` (`ContactRequestRemovalFFI::contact_id` names the contact). +/// - `cleared_identities`: 32-byte ids of identities the round removed from +/// the wallet. Delete each one's position and every address it owns, so +/// an identity added back later reads the store from its start. +/// +/// Every pointer is null when its count is 0 and valid only for the +/// duration of the call. A host without the slot never has the state +/// written, and each launch reads the store from its start. +pub type PersistPrivateContactScanStateFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + removed_addresses: *const ContactRequestRemovalFFI, + removed_addresses_count: usize, + cleared_identities: *const [u8; 32], + cleared_identities_count: usize, +) -> i32; + +/// Return every stored private contact request read position and shielded +/// address for `wallet_id`. The host allocates both arrays and keeps them +/// valid until Rust hands them back through +/// [`FreePrivateContactScanStateFn`]; a null array with a zero count is an +/// empty one. An unreadable store must return an error, not empty arrays. +pub type LoadPrivateContactScanStateFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_positions: *mut *const PrivateContactRequestsPositionFFI, + out_positions_count: *mut usize, + out_addresses: *mut *const PrivateShieldedAddressFFI, + out_addresses_count: *mut usize, +) -> i32; + +/// Release the arrays a [`LoadPrivateContactScanStateFn`] returned. +pub type FreePrivateContactScanStateFn = unsafe extern "C" fn( + context: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, +); + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -489,6 +551,48 @@ pub struct PersistenceCallbacksExtension { out_operation_id: *mut u8, ) -> i32, >, + /// The private contact request scan state a round writes (see + /// [`PersistPrivateContactScanStateFn`]). Appended under the same + /// version for the same reason as the slots above. Wired together with + /// the load + free pair below: `PRIVATE_CONTACT_REQUEST_SCAN_STATE` is + /// attested only when all three are present (and declared). + #[allow(clippy::type_complexity)] + pub on_persist_private_contact_scan_state_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + removed_addresses: *const ContactRequestRemovalFFI, + removed_addresses_count: usize, + cleared_identities: *const [u8; 32], + cleared_identities_count: usize, + ) -> i32, + >, + /// Return the stored scan state for a wallet (see + /// [`LoadPrivateContactScanStateFn`]). + pub on_load_private_contact_scan_state_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_positions: *mut *const PrivateContactRequestsPositionFFI, + out_positions_count: *mut usize, + out_addresses: *mut *const PrivateShieldedAddressFFI, + out_addresses_count: *mut usize, + ) -> i32, + >, + /// Release the arrays `on_load_private_contact_scan_state_fn` returned. + pub on_load_private_contact_scan_state_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + ), + >, } impl Default for PersistenceCallbacksExtension { @@ -509,6 +613,9 @@ impl Default for PersistenceCallbacksExtension { on_persist_wallet_dashpay_backfill_fn: None, on_persist_asset_lock_operation_id_fn: None, on_load_asset_lock_operation_id_fn: None, + on_persist_private_contact_scan_state_fn: None, + on_load_private_contact_scan_state_fn: None, + on_load_private_contact_scan_state_free_fn: None, } } } @@ -530,6 +637,9 @@ pub struct PersistenceExtensionCallbacks { pub wallet_dashpay_backfill: Option, pub persist_asset_lock_operation_id: Option, pub load_asset_lock_operation_id: Option, + pub persist_private_contact_scan_state: Option, + pub load_private_contact_scan_state: Option, + pub load_private_contact_scan_state_free: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1685,6 +1795,30 @@ impl FFIPersister { if self.callbacks.on_persist_token_balances_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); } + // The scan state is restored per wallet during the wallet-list load, + // so that load is required alongside the extension trio. So is the + // contacts callback: a read position vouches for the contact + // requests read before it, and without that callback `store` skips + // them, so a restored position would skip entries whose requests + // were never kept. + if wallet_restore + && self.callbacks.on_persist_contacts_fn.is_some() + && self + .tracked_masternodes_callbacks + .persist_private_contact_scan_state + .is_some() + && self + .tracked_masternodes_callbacks + .load_private_contact_scan_state + .is_some() + && self + .tracked_masternodes_callbacks + .load_private_contact_scan_state_free + .is_some() + { + capabilities = + capabilities.union(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE); + } // The dashpay-payments slot is what the sweep's Failed flip rides // (`dashpay_payments_overlay` on the store round). A host that // never wired it — Android deliberately keeps payment recording @@ -2942,6 +3076,28 @@ impl PlatformWalletPersistence for FFIPersister { } } + // The private contact request scan state, after the contacts it was + // read with. Identities the round removed take theirs with them. + if let Some(cb) = self + .tracked_masternodes_callbacks + .persist_private_contact_scan_state + { + let rows = PrivateContactScanStateRows::new( + changeset.contacts.as_ref(), + changeset.identities.as_ref().map(|id_cs| &id_cs.removed), + ); + if !rows.is_empty() { + let rc = unsafe { rows.persist(cb, self.callbacks.context, &wallet_id) }; + if rc != 0 { + eprintln!( + "Private contact request scan state persistence callback returned error code {}", + rc + ); + outcome.record(rc); + } + } + } + // Send sync state updates. if let Some(ref addr_cs) = changeset.platform_addresses { if let Some(cb) = self.callbacks.on_persist_sync_state_fn { @@ -3480,6 +3636,27 @@ impl PlatformWalletPersistence for FFIPersister { &mut wallet_state.unused_asset_locks, )?; } + if let (Some(load), Some(free)) = ( + self.tracked_masternodes_callbacks + .load_private_contact_scan_state, + self.tracked_masternodes_callbacks + .load_private_contact_scan_state_free, + ) { + // Read only for a host that attests the state: the manager + // drops any other host's copy at load. + if self + .persistence_capabilities() + .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE) + { + load_private_contact_scan_state( + load, + free, + self.callbacks.context, + &entry.wallet_id, + &mut wallet_state.identity_manager, + )?; + } + } if let Some(cb) = self.load_identity_balance_block_time_callback { for identities in wallet_state.identity_manager.wallet_identities.values_mut() { for managed in identities.values_mut() { @@ -6243,6 +6420,204 @@ fn load_asset_lock_operation_ids( Ok(()) } +/// One round's private contact request scan state in its FFI shape (see +/// [`PersistPrivateContactScanStateFn`]). +struct PrivateContactScanStateRows { + positions: Vec, + addresses: Vec, + removed_addresses: Vec, + cleared_identities: Vec<[u8; 32]>, +} + +impl PrivateContactScanStateRows { + fn new( + contacts: Option<&ContactChangeSet>, + removed_identities: Option<&BTreeSet>, + ) -> Self { + let mut rows = Self { + positions: Vec::new(), + addresses: Vec::new(), + removed_addresses: Vec::new(), + cleared_identities: removed_identities + .into_iter() + .flatten() + .map(|id| id.to_buffer()) + .collect(), + }; + if let Some(contacts) = contacts { + rows.positions = contacts + .private_contact_requests_positions + .iter() + .map( + |(identity_id, position)| PrivateContactRequestsPositionFFI { + identity_id: identity_id.to_buffer(), + position: position.position, + key_fingerprint: position.key_fingerprint, + private_payments_unresolved: position.private_payments_unresolved, + }, + ) + .collect(); + rows.addresses = contacts + .private_shielded_addresses + .iter() + .map( + |((owner_id, contact_id), address)| PrivateShieldedAddressFFI { + owner_id: owner_id.to_buffer(), + contact_id: contact_id.to_buffer(), + address: *address, + }, + ) + .collect(); + rows.removed_addresses = contacts + .removed_private_shielded_addresses + .iter() + .map(|(owner_id, contact_id)| ContactRequestRemovalFFI { + owner_id: owner_id.to_buffer(), + contact_id: contact_id.to_buffer(), + }) + .collect(); + } + rows + } + + fn is_empty(&self) -> bool { + self.positions.is_empty() + && self.addresses.is_empty() + && self.removed_addresses.is_empty() + && self.cleared_identities.is_empty() + } + + /// # Safety + /// `callback` must be a valid [`PersistPrivateContactScanStateFn`] for + /// `context`. + unsafe fn persist( + &self, + callback: PersistPrivateContactScanStateFn, + context: *mut c_void, + wallet_id: &WalletId, + ) -> i32 { + fn ptr(rows: &[T]) -> *const T { + if rows.is_empty() { + std::ptr::null() + } else { + rows.as_ptr() + } + } + callback( + context, + wallet_id.as_ptr(), + ptr(&self.positions), + self.positions.len(), + ptr(&self.addresses), + self.addresses.len(), + ptr(&self.removed_addresses), + self.removed_addresses.len(), + ptr(&self.cleared_identities), + self.cleared_identities.len(), + ) + } +} + +/// Restore a wallet's private contact request scan state onto the identities +/// its load restored. Rows for an identity the load did not restore are +/// passed over. An error from the host, or a missing buffer, fails the load. +fn load_private_contact_scan_state( + load: LoadPrivateContactScanStateFn, + free: FreePrivateContactScanStateFn, + context: *mut c_void, + wallet_id: &WalletId, + identity_manager: &mut IdentityManagerStartState, +) -> Result<(), PersistenceError> { + struct Guard { + free: FreePrivateContactScanStateFn, + context: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + } + impl Drop for Guard { + fn drop(&mut self) { + if self.positions.is_null() && self.addresses.is_null() { + return; + } + unsafe { + (self.free)( + self.context, + self.positions, + self.positions_count, + self.addresses, + self.addresses_count, + ) + }; + } + } + + let mut guard = Guard { + free, + context, + positions: std::ptr::null(), + positions_count: 0, + addresses: std::ptr::null(), + addresses_count: 0, + }; + let rc = unsafe { + load( + context, + wallet_id.as_ptr(), + &mut guard.positions, + &mut guard.positions_count, + &mut guard.addresses, + &mut guard.addresses_count, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_private_contact_scan_state_fn returned error code {rc}"), + )); + } + if (guard.positions.is_null() && guard.positions_count != 0) + || (guard.addresses.is_null() && guard.addresses_count != 0) + { + return Err(PersistenceError::backend( + "private contact request scan state: missing row buffer", + )); + } + let positions: &[PrivateContactRequestsPositionFFI] = if guard.positions_count == 0 { + &[] + } else { + unsafe { slice::from_raw_parts(guard.positions, guard.positions_count) } + }; + let addresses: &[PrivateShieldedAddressFFI] = if guard.addresses_count == 0 { + &[] + } else { + unsafe { slice::from_raw_parts(guard.addresses, guard.addresses_count) } + }; + + let mut identities: BTreeMap = identity_manager + .wallet_identities + .values_mut() + .flat_map(BTreeMap::values_mut) + .map(|managed| (managed.id(), managed)) + .collect(); + for row in positions { + if let Some(managed) = identities.get_mut(&Identifier::new(row.identity_id)) { + managed.apply_private_contact_requests_position(PrivateContactRequestsPosition { + position: row.position, + key_fingerprint: row.key_fingerprint, + private_payments_unresolved: row.private_payments_unresolved, + }); + } + } + for row in addresses { + if let Some(managed) = identities.get_mut(&Identifier::new(row.owner_id)) { + managed.apply_private_shielded_address(Identifier::new(row.contact_id), row.address); + } + } + Ok(()) +} + /// Rebuild tracked locks from the host's frozen `AssetLockEntryFFI` rows. /// Transactions use consensus encoding, proofs use DPP bincode, and outpoints /// are raw txid bytes followed by a little-endian vout. Malformed or duplicate @@ -7678,6 +8053,433 @@ mod tests { ); } + /// What a scan-state host received, by callback. + #[derive(Default)] + struct ScanStateHost { + positions: std::sync::Mutex>, + addresses: std::sync::Mutex>, + removed: std::sync::Mutex>, + cleared: std::sync::Mutex>, + /// What `load_scan_state` answers: 0 rows as stored, 1 an error, + /// 2 a count without a buffer. + load_mode: u8, + loads: std::sync::atomic::AtomicUsize, + frees: std::sync::atomic::AtomicUsize, + } + + #[allow(clippy::too_many_arguments)] + unsafe extern "C" fn persist_scan_state( + ctx: *mut c_void, + wallet: *const u8, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + removed: *const ContactRequestRemovalFFI, + removed_count: usize, + cleared: *const [u8; 32], + cleared_count: usize, + ) -> i32 { + unsafe fn rows<'a, T>(ptr: *const T, count: usize) -> &'a [T] { + if count == 0 { + assert!(ptr.is_null(), "an empty array crosses as null"); + &[] + } else { + std::slice::from_raw_parts(ptr, count) + } + } + assert_eq!(std::slice::from_raw_parts(wallet, 32), &[42; 32]); + let host = &*(ctx as *const ScanStateHost); + host.positions + .lock() + .unwrap() + .extend_from_slice(rows(positions, positions_count)); + host.addresses + .lock() + .unwrap() + .extend_from_slice(rows(addresses, addresses_count)); + host.removed.lock().unwrap().extend( + rows(removed, removed_count) + .iter() + .map(|row| (row.owner_id, row.contact_id)), + ); + host.cleared + .lock() + .unwrap() + .extend_from_slice(rows(cleared, cleared_count)); + 0 + } + + unsafe extern "C" fn load_scan_state( + ctx: *mut c_void, + wallet: *const u8, + out_positions: *mut *const PrivateContactRequestsPositionFFI, + out_positions_count: *mut usize, + out_addresses: *mut *const PrivateShieldedAddressFFI, + out_addresses_count: *mut usize, + ) -> i32 { + assert_eq!(std::slice::from_raw_parts(wallet, 32), &[42; 32]); + let host = &*(ctx as *const ScanStateHost); + host.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + match host.load_mode { + 1 => return -1, + 2 => { + *out_positions = std::ptr::null(); + *out_positions_count = 1; + return 0; + } + _ => {} + } + let positions = host.positions.lock().unwrap().clone(); + let addresses = host.addresses.lock().unwrap().clone(); + *out_positions_count = positions.len(); + *out_positions = Box::into_raw(positions.into_boxed_slice()).cast(); + *out_addresses_count = addresses.len(); + *out_addresses = Box::into_raw(addresses.into_boxed_slice()).cast(); + 0 + } + + unsafe extern "C" fn free_scan_state( + ctx: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + ) { + let host = &*(ctx as *const ScanStateHost); + host.frees.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if !positions.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + positions.cast_mut(), + positions_count, + ))); + } + if !addresses.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + addresses.cast_mut(), + addresses_count, + ))); + } + } + + unsafe extern "C" fn load_one_identity( + _: *mut c_void, + entries: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + let mut identity: IdentityRestoreEntryFFI = std::mem::zeroed(); + identity.identity_id = [7; 32]; + *entries = Box::into_raw(Box::new(WalletRestoreEntryFFI { + wallet_id: [42; 32], + identities: Box::into_raw(Box::new(identity)), + identities_count: 1, + ..Default::default() + })); + *count = 1; + 0 + } + + unsafe extern "C" fn free_one_identity( + _: *mut c_void, + entries: *const WalletRestoreEntryFFI, + _: usize, + ) { + let row = Box::from_raw(entries.cast_mut()); + drop(Box::from_raw(row.identities.cast_mut())); + } + + #[allow(clippy::too_many_arguments)] + unsafe extern "C" fn keep_scan_state_contacts( + _: *mut c_void, + _: *const u8, + _: *const ContactRequestFFI, + _: usize, + _: *const ContactRequestRemovalFFI, + _: usize, + _: *const ContactRequestRemovalFFI, + _: usize, + _: *const ContactIgnoredSenderFFI, + _: usize, + ) -> i32 { + 0 + } + + fn scan_state_persister( + host: &ScanStateHost, + declared: PersistenceCapabilities, + extensions: PersistenceExtensionCallbacks, + ) -> FFIPersister { + scan_state_persister_with_contacts(host, declared, extensions, true) + } + + fn scan_state_persister_with_contacts( + host: &ScanStateHost, + declared: PersistenceCapabilities, + extensions: PersistenceExtensionCallbacks, + persists_contacts: bool, + ) -> FFIPersister { + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (host as *const ScanStateHost).cast_mut().cast(), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), + on_load_wallet_list_fn: Some(load_one_identity), + on_load_wallet_list_free_fn: Some(free_one_identity), + on_persist_contacts_fn: persists_contacts.then_some(keep_scan_state_contacts), + ..Default::default() + }, + declared, + extensions, + ) + } + + fn scan_state_trio() -> PersistenceExtensionCallbacks { + PersistenceExtensionCallbacks { + persist_private_contact_scan_state: Some(persist_scan_state), + load_private_contact_scan_state: Some(load_scan_state), + load_private_contact_scan_state_free: Some(free_scan_state), + ..Default::default() + } + } + + /// A round carrying a read position, an address, an address removal and + /// a removed identity reaches the host as those four arrays. + #[test] + fn should_persist_the_private_contact_request_scan_state_of_a_round() { + use platform_wallet::changeset::IdentityChangeSet; + + let host = ScanStateHost::default(); + let persister = scan_state_persister( + &host, + PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE), + scan_state_trio(), + ); + assert!(persister + .persistence_capabilities() + .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE)); + + let owner = Identifier::from([7; 32]); + let mut contacts = ContactChangeSet::default(); + contacts.private_contact_requests_positions.insert( + owner, + PrivateContactRequestsPosition { + position: 12, + key_fingerprint: [3; 32], + private_payments_unresolved: true, + }, + ); + contacts + .private_shielded_addresses + .insert((owner, Identifier::from([5; 32])), [9; 43]); + contacts + .removed_private_shielded_addresses + .insert((owner, Identifier::from([6; 32]))); + let identities = IdentityChangeSet { + removed: BTreeSet::from([Identifier::from([8; 32])]), + ..Default::default() + }; + persister + .store( + [42; 32], + PlatformWalletChangeSet { + contacts: Some(contacts), + identities: Some(identities), + ..Default::default() + }, + ) + .expect("store the scan state"); + + assert_eq!( + *host.positions.lock().unwrap(), + vec![PrivateContactRequestsPositionFFI { + identity_id: [7; 32], + position: 12, + key_fingerprint: [3; 32], + private_payments_unresolved: true, + }] + ); + assert_eq!( + *host.addresses.lock().unwrap(), + vec![PrivateShieldedAddressFFI { + owner_id: [7; 32], + contact_id: [5; 32], + address: [9; 43], + }] + ); + assert_eq!(*host.removed.lock().unwrap(), vec![([7; 32], [6; 32])]); + assert_eq!(*host.cleared.lock().unwrap(), vec![[8; 32]]); + } + + /// A load hands the stored position and address back to the identity + /// they belong to, passes over rows of an identity it did not restore, + /// returns the host's arrays, and fails on a host error or a count + /// without a buffer. + #[test] + fn should_restore_the_private_contact_request_scan_state_onto_its_identity() { + use platform_wallet::wallet::identity::ContactRequest; + + let declared = PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE; + let host = ScanStateHost::default(); + host.positions.lock().unwrap().extend([ + PrivateContactRequestsPositionFFI { + identity_id: [7; 32], + position: 12, + key_fingerprint: [3; 32], + private_payments_unresolved: true, + }, + PrivateContactRequestsPositionFFI { + identity_id: [8; 32], + position: 99, + key_fingerprint: [4; 32], + private_payments_unresolved: false, + }, + ]); + host.addresses.lock().unwrap().extend([ + PrivateShieldedAddressFFI { + owner_id: [7; 32], + contact_id: [5; 32], + address: [9; 43], + }, + PrivateShieldedAddressFFI { + owner_id: [8; 32], + contact_id: [5; 32], + address: [1; 43], + }, + ]); + let mut restored = scan_state_persister(&host, declared, scan_state_trio()) + .load() + .expect("restore the scan state"); + assert_eq!(host.loads.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(host.frees.load(std::sync::atomic::Ordering::SeqCst), 1); + let wallet = restored.wallets.get_mut(&[42; 32]).expect("wallet"); + let identities = &mut wallet.identity_manager.wallet_identities; + assert_eq!(identities[&[42; 32]].len(), 1, "no identity is invented"); + let managed = identities + .get_mut(&[42; 32]) + .and_then(|identities| identities.get_mut(&0)) + .expect("identity"); + assert_eq!(managed.dashpay().private_contact_requests_position(), 12); + assert!( + managed.dashpay().private_payments_unresolved(), + "the unresolved-payment marker is restored with the position" + ); + // The address shows once the contact's request to us came privately + // (a private request carries no core height). + let owner = Identifier::from([7; 32]); + let contact = Identifier::from([5; 32]); + managed.apply_incoming_contact_request(ContactRequest::new( + contact, + owner, + 0, + 0, + 0, + vec![0; 96], + 0, + 0, + )); + assert_eq!( + managed + .dashpay() + .private_contact(&contact) + .and_then(|private| private.shielded_address), + Some([9; 43]) + ); + + for (mode, what) in [(1, "a host error"), (2, "a count without a buffer")] { + let failing = ScanStateHost { + load_mode: mode, + ..Default::default() + }; + assert!( + scan_state_persister(&failing, declared, scan_state_trio()) + .load() + .is_err(), + "{what} fails the load" + ); + } + } + + /// A host that does not declare the capability, wires only part of the + /// trio, or keeps no contact requests, is not attested, has nothing read + /// at load, and still stores and loads everything else. A host without + /// the slots stores a round carrying scan state without error. + #[test] + fn should_leave_hosts_without_the_scan_state_capability_unchanged() { + let host = ScanStateHost::default(); + host.positions + .lock() + .unwrap() + .push(PrivateContactRequestsPositionFFI { + identity_id: [7; 32], + position: 12, + key_fingerprint: [3; 32], + private_payments_unresolved: true, + }); + let undeclared = + scan_state_persister(&host, PersistenceCapabilities::NONE, scan_state_trio()); + let half_wired = scan_state_persister( + &host, + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + PersistenceExtensionCallbacks { + persist_private_contact_scan_state: Some(persist_scan_state), + load_private_contact_scan_state: Some(load_scan_state), + ..Default::default() + }, + ); + let unwired = scan_state_persister( + &host, + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + PersistenceExtensionCallbacks::default(), + ); + // A read position vouches for the requests read before it, which a + // host without the contacts callback never keeps. + let without_contacts = scan_state_persister_with_contacts( + &host, + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + scan_state_trio(), + false, + ); + for persister in [&undeclared, &half_wired, &unwired, &without_contacts] { + assert!(!persister + .persistence_capabilities() + .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE)); + let restored = persister.load().expect("the rest still restores"); + assert_eq!( + restored.wallets[&[42; 32]] + .identity_manager + .wallet_identities[&[42; 32]][&0] + .dashpay() + .private_contact_requests_position(), + 0 + ); + } + assert_eq!( + host.loads.load(std::sync::atomic::Ordering::SeqCst), + 0, + "nothing is read from a host that does not attest the state" + ); + + let mut contacts = ContactChangeSet::default(); + contacts.private_contact_requests_positions.insert( + Identifier::from([7; 32]), + PrivateContactRequestsPosition { + position: 1, + key_fingerprint: [0; 32], + private_payments_unresolved: false, + }, + ); + unwired + .store( + [42; 32], + PlatformWalletChangeSet { + contacts: Some(contacts), + ..Default::default() + }, + ) + .expect("a host without the slots stores the round"); + } + #[test] fn should_persist_balance_watermarks_only_for_the_owning_wallet() { use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; @@ -10268,6 +11070,36 @@ mod tests { PersistenceCallbacksExtension, on_load_asset_lock_operation_id_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_private_contact_scan_state_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_private_contact_scan_state_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_free_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_free_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -10330,6 +11162,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS, PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE, + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs new file mode 100644 index 00000000000..28c785b24c3 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -0,0 +1,1335 @@ +//! FFI bindings for DashPay private contact requests (protocol version 15). +//! +//! A private contact request is an entry of DashPay's `privateContactRequest` +//! store, sealed to one of the recipient's DashPay keys and paid for from the +//! shielded pool, so nothing on chain names either side. The wallet keeps a +//! private request as it keeps a public one, an ordinary +//! [`ContactRequest`](platform_wallet::ContactRequest), so the existing +//! contact-request rows and accessors read it. +//! +//! - [`platform_wallet_sync_private_contact_requests`]: read the store for the +//! wallet's identities and ingest what it holds for them. The store is +//! opened with keys that stay in the resolver-backed signer; each request's +//! signature is checked against its sender's keys, which needs no secret. +//! - [`platform_wallet_manager_send_private_contact_request`] / +//! [`platform_wallet_manager_accept_private_contact_request`]: send, or +//! accept with a private reply, paying from an account's shielded notes. +//! The request is signed by the identity signer the public send and accept +//! take, and sealed in the resolver-backed signer. Built with the +//! `shielded` feature only. +//! - [`platform_wallet_manager_accept_contact_request_replying_in_kind`]: +//! the one accept a host calls. The wallet replies privately to a request +//! that came privately, and with a document otherwise. Exported in every +//! build; without the `shielded` feature a private reply fails with +//! `ErrorShieldedBuildRequired`. +//! - [`managed_identity_get_private_contact`]: what a contact's private +//! requests carry beyond the `ContactRequest`: whether they came or went +//! privately, and the contact's shielded address. +//! - [`managed_identity_get_private_reply_pending`]: whether a private request +//! to a contact is in flight or not yet read back, so a host offers no +//! second send or reply. +//! +//! The explicitly public send and accept refuse a contact whose requests came +//! or went privately with `ErrorPrivateContactRequestNeedsPrivateReply`. + +use std::sync::{Arc, Mutex}; + +use platform_wallet::{PlatformWalletError, PrivateContact}; +use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle, VTableSigner}; + +use crate::contact_request::CONTACT_REQUEST_STORAGE; +use crate::dashpay::{resolver_contact_crypto_provider, ContactRequestHandleArray}; +use crate::error::*; +use crate::established_contact::ESTABLISHED_CONTACT_STORAGE; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::types::*; +use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; + +#[cfg(not(feature = "shielded"))] +use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; + +#[cfg(feature = "shielded")] +use std::ffi::CStr; +#[cfg(feature = "shielded")] +use std::os::raw::c_char; +#[cfg(feature = "shielded")] +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(feature = "shielded")] +use platform_wallet::wallet::shielded::{CachedOrchardProver, NetworkShieldedCoordinator}; +#[cfg(feature = "shielded")] +use platform_wallet::{AutoAcceptProofSource, PrivateReplyPayment}; +#[cfg(feature = "shielded")] +use zeroize::Zeroizing; + +#[cfg(feature = "shielded")] +use crate::dashpay::read_auto_accept_proof; +#[cfg(feature = "shielded")] +use crate::identity_keys_from_mnemonic::resolve_seed_from_resolver; +#[cfg(feature = "shielded")] +use crate::shielded_send::{ + catch_spend_panic, map_spend_result, poke_sync_on_unconfirmed, resolve_shielded_coordinator, + resolve_wallet_and_coordinator, +}; + +/// The bytes of an Orchard payment address: an 11-byte diversifier and a +/// 32-byte transmission key. +const SHIELDED_ADDRESS_LEN: usize = 43; + +/// A private sync, and the store read a send or accept makes first, check +/// that the resolver's seed is the wallet's before reading anything. A +/// mismatch takes the code every DashPay export gives it, and the seed +/// check reports (`ErrorInvalidParameter`); any other error is handed back. +fn seed_mismatch_result( + error: PlatformWalletError, +) -> Result { + match error { + error @ PlatformWalletError::SeedMismatch { .. } => Ok(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + error.to_string(), + )), + error => Err(error), + } +} + +/// What a pass over DashPay's private contact request store did +/// (`PrivateContactSyncReport`). Counts above `u32::MAX` saturate. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PrivateContactSyncReportFFI { + /// The store position the pass stopped at. + pub next_position: u64, + /// The store pages read. + pub pages_read: u32, + /// Entries that opened for one of the wallet's identities but were + /// refused. + pub rejected_count: u32, + /// Identities whose ingest did not reach disk: their entries are read + /// again by the next pass. + pub unpersisted_identity_count: u32, + /// Whether the pass read to the store's last entry. When false, call + /// again to read on. + pub complete: bool, +} + +// Expected `PrivateContactSyncReportFFI` layout on 64-bit targets: +// +// 0..=7 next_position u64 +// 8..=11 pages_read u32 +// 12..=15 rejected_count u32 +// 16..=19 unpersisted_identity_count u32 +// 20 complete bool +// 21..=23 (tail padding to 8) +// +// Total size = 24, alignment = 8 (from the u64). +const _: [u8; 24] = [0u8; std::mem::size_of::()]; +const _: [u8; 8] = [0u8; std::mem::align_of::()]; +const _: () = assert!(std::mem::offset_of!(PrivateContactSyncReportFFI, pages_read) == 8); +const _: () = assert!(std::mem::offset_of!(PrivateContactSyncReportFFI, rejected_count) == 12); +const _: () = + assert!(std::mem::offset_of!(PrivateContactSyncReportFFI, unpersisted_identity_count) == 16); +const _: () = assert!(std::mem::offset_of!(PrivateContactSyncReportFFI, complete) == 20); + +fn saturating_count(count: usize) -> u32 { + u32::try_from(count).unwrap_or(u32::MAX) +} + +/// Read DashPay's private contact request store for every identity of the +/// wallet, at most `max_pages` pages from the earliest of their cursors, and +/// ingest what it holds for them: requests to them, each sender checked, and +/// requests they sent, recovered from the store alone. Contacts establish and +/// their DashPay accounts are queued as the document sync does. +/// +/// `core_signer_handle` is the wallet-HD resolver signer: the store is opened +/// with keys derived inside it. Each request's signature is checked against +/// its sender's keys, which needs no secret. The cursors live in memory only, +/// so the first pass of a process reads the store from its start. +/// +/// Writes the requests the pass ingested, received and sent (including ones +/// already held), to `out_requests` (release with +/// `platform_wallet_contact_request_handle_array_free`, each handle with +/// `contact_request_destroy`) and the pass's counters to `out_report`. +/// +/// # Safety +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`; the caller pins it for the call. +/// - `out_requests` and `out_report` must be valid, writable pointers. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_sync_private_contact_requests( + wallet_handle: Handle, + core_signer_handle: *mut MnemonicResolverHandle, + max_pages: u32, + out_requests: *mut ContactRequestHandleArray, + out_report: *mut PrivateContactSyncReportFFI, +) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); + check_ptr!(out_requests); + check_ptr!(out_report); + // Publish FFI-safe sentinels before any fallible work, so every early + // return leaves both out-params well-defined. + unsafe { + *out_requests = ContactRequestHandleArray::empty(); + *out_report = PrivateContactSyncReportFFI::default(); + } + + // Look the wallet up under the registry guard, but run the pass outside + // it: a pass reads many pages, and a registry read guard held across + // that wait would stall `platform_wallet_destroy` and, through + // parking_lot's writer preference, every other registry reader. + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + ( + wallet.identity().clone(), + wallet.wallet_id(), + wallet.network(), + ) + }); + let (identity, wallet_id, network) = unwrap_option_or_return!(option); + // SAFETY: the caller pins the resolver handle for the duration of this + // call. + let provider = + unsafe { resolver_contact_crypto_provider(core_signer_handle, wallet_id, network) }; + let result = block_on_worker(async move { + identity + .dashpay() + .sync_private_contact_requests(&provider, max_pages as usize) + .await + }); + let report = match result { + Ok(report) => report, + Err(error) => return seed_mismatch_result(error).unwrap_or_else(|error| error.into()), + }; + unsafe { + *out_report = PrivateContactSyncReportFFI { + next_position: report.next_position, + pages_read: saturating_count(report.pages_read), + rejected_count: saturating_count(report.rejected.len()), + unpersisted_identity_count: saturating_count(report.unpersisted_identities.len()), + complete: report.complete, + }; + *out_requests = ContactRequestHandleArray::from_requests(report.requests); + } + PlatformWalletFFIResult::ok() +} + +/// What an identity's private contact requests with one contact carry beyond +/// the `ContactRequest` (`PrivateContact`). +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PrivateContactFFI { + /// The contact's request came privately: accepting it replies privately. + pub received_privately: bool, + /// This identity's request to the contact went privately. + pub sent_privately: bool, + /// Whether `shielded_address` holds the contact's address. + pub has_shielded_address: bool, + /// The Orchard address the contact asked to be paid at privately, when + /// `has_shielded_address`; zeros otherwise. + pub shielded_address: [u8; 43], +} + +// Expected `PrivateContactFFI` layout on all targets: +// +// 0 received_privately bool +// 1 sent_privately bool +// 2 has_shielded_address bool +// 3..=45 shielded_address [u8; 43] +// +// Total size = 46, alignment = 1 (all-byte fields). +const _: [u8; 46] = [0u8; std::mem::size_of::()]; +const _: [u8; 1] = [0u8; std::mem::align_of::()]; +const _: () = assert!(std::mem::offset_of!(PrivateContactFFI, sent_privately) == 1); +const _: () = assert!(std::mem::offset_of!(PrivateContactFFI, has_shielded_address) == 2); +const _: () = assert!(std::mem::offset_of!(PrivateContactFFI, shielded_address) == 3); + +impl PrivateContactFFI { + fn empty() -> Self { + Self { + received_privately: false, + sent_privately: false, + has_shielded_address: false, + shielded_address: [0; SHIELDED_ADDRESS_LEN], + } + } +} + +impl From for PrivateContactFFI { + fn from(contact: PrivateContact) -> Self { + Self { + received_privately: contact.received_privately, + sent_privately: contact.sent_privately, + has_shielded_address: contact.shielded_address.is_some(), + shielded_address: contact + .shielded_address + .unwrap_or([0; SHIELDED_ADDRESS_LEN]), + } + } +} + +/// Read what this identity's private contact requests with `contact_id` +/// carry into `out_contact`. Returns `NotFound` when neither tracked request +/// between them came or went privately. Whether one did is the stored +/// request's own, so it holds across restarts; the contact's shielded address +/// is kept in memory only and rebuilt by +/// `platform_wallet_sync_private_contact_requests`, so until a pass has run +/// it is absent. +/// +/// # Safety +/// - `contact_id` must point to 32 readable bytes. +/// - `out_contact` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn managed_identity_get_private_contact( + identity_handle: Handle, + contact_id: *const u8, + out_contact: *mut PrivateContactFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_contact); + unsafe { *out_contact = PrivateContactFFI::empty() }; + let contact_id = unwrap_result_or_return!(unsafe { read_identifier(contact_id) }); + + let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { + identity + .dashpay() + .private_contact(&contact_id) + .map(PrivateContactFFI::from) + }); + let inner = unwrap_option_or_return!(option); + let contact = unwrap_option_or_return!(inner); + unsafe { *out_contact = contact }; + PlatformWalletFFIResult::ok() +} + +/// Whether a private contact request from this identity to `contact_id` is +/// being paid for, or was paid for and is not yet read back from the store +/// (`DashPayState::private_reply_pending`), into `out_pending`. While it is, +/// the wallet refuses another private send or reply to the contact with +/// `ErrorPrivateContactRequestPending`, so a host shows the contact as pending +/// and offers neither; a later `platform_wallet_sync_private_contact_requests` +/// settles it. A pure read of the managed-identity snapshot. The wallet keeps +/// it in memory only, so it is false after a restart. +/// +/// # Safety +/// - `contact_id` must point to 32 readable bytes. +/// - `out_pending` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn managed_identity_get_private_reply_pending( + identity_handle: Handle, + contact_id: *const u8, + out_pending: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_pending); + unsafe { *out_pending = false }; + let contact_id = unwrap_result_or_return!(unsafe { read_identifier(contact_id) }); + let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { + identity.dashpay().private_reply_pending(&contact_id) + }); + let pending = unwrap_option_or_return!(option); + unsafe { *out_pending = pending }; + PlatformWalletFFIResult::ok() +} + +/// Copy an optional Orchard address: null means none. +/// +/// # Safety +/// A non-null `ptr` must point to [`SHIELDED_ADDRESS_LEN`] readable bytes. +unsafe fn read_shielded_address(ptr: *const u8) -> Option<[u8; SHIELDED_ADDRESS_LEN]> { + if ptr.is_null() { + return None; + } + let mut address = [0u8; SHIELDED_ADDRESS_LEN]; + std::ptr::copy_nonoverlapping(ptr, address.as_mut_ptr(), SHIELDED_ADDRESS_LEN); + Some(address) +} + +/// Send a DashPay private contact request from `sender_identity_id` to +/// `recipient_identity_id`, paying for it from `account`'s shielded notes, so +/// nothing on chain names either identity. The request carries our receiving +/// xpub for the contact, the masked account reference, `account_label`, +/// `auto_accept_proof`, and `shielded_address`, the Orchard address the +/// contact may pay us at privately. It is sealed to the recipient's DashPay +/// key inside the resolver-backed signer. +/// +/// `mnemonic_resolver_handle` supplies both the transient Orchard spend +/// authority and the DashPay keys. `signer_handle` is the identity signer +/// the public send takes (`platform_wallet_send_contact_request_with_signer`): +/// it signs the request with one of the sender's authentication keys, as it +/// signs a transition. Writes a handle into `CONTACT_REQUEST_STORAGE` to +/// `out_request_handle` (release with `contact_request_destroy`). +/// +/// Errors follow the shielded spend contract: `ErrorShieldedSpendUnconfirmed` +/// means the payment may already be on chain; do not send again. +/// +/// # Safety +/// - `wallet_id_bytes`, `sender_identity_id` and `recipient_identity_id` must +/// each point to 32 readable bytes. +/// - `mnemonic_resolver_handle` must be a valid, non-destroyed handle; the +/// caller retains ownership and pins it for the call. +/// - `account_label`, when non-null, must be a valid NUL-terminated UTF-8 C +/// string. +/// - `auto_accept_proof`, when non-null, must point to +/// `auto_accept_proof_len` readable bytes; `(null, 0)` omits it. +/// - `shielded_address`, when non-null, must point to 43 readable bytes; +/// null omits it. +/// - `signer_handle` must be a valid, non-destroyed `*mut SignerHandle` +/// produced by `dash_sdk_signer_create_with_ctx`; the caller retains +/// ownership and pins it for the call. +/// - `out_request_handle` must be a valid, writable pointer. +#[cfg(feature = "shielded")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_send_private_contact_request( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + 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, + shielded_address: *const u8, + signer_handle: *mut SignerHandle, + out_request_handle: *mut Handle, +) -> PlatformWalletFFIResult { + // A panic in the proving task must not reach this `extern "C"` frame; the + // notes may already be spent when it strikes, so the ambiguous + // spend-unconfirmed contract applies (see `catch_spend_panic`). + catch_spend_panic("private contact request", || { + send_private_contact_request_inner( + handle, + wallet_id_bytes, + mnemonic_resolver_handle, + account, + sender_identity_id, + recipient_identity_id, + account_label, + auto_accept_proof, + auto_accept_proof_len, + shielded_address, + signer_handle, + out_request_handle, + ) + }) +} + +/// Body of [`platform_wallet_manager_send_private_contact_request`], as an +/// ordinary Rust function so a panic unwinds into [`catch_spend_panic`]. +/// +/// # Safety +/// Identical contract to the export that calls it. +#[cfg(feature = "shielded")] +#[allow(clippy::too_many_arguments)] +unsafe fn send_private_contact_request_inner( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + 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, + shielded_address: *const u8, + signer_handle: *mut SignerHandle, + out_request_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(signer_handle); + check_ptr!(out_request_handle); + unsafe { *out_request_handle = NULL_HANDLE }; + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let sender = unwrap_result_or_return!(read_identifier(sender_identity_id)); + let recipient = unwrap_result_or_return!(read_identifier(recipient_identity_id)); + let label = if account_label.is_null() { + None + } else { + Some(unwrap_result_or_return!(CStr::from_ptr(account_label).to_str()).to_string()) + }; + let proof = match read_auto_accept_proof(auto_accept_proof, auto_accept_proof_len) { + Ok(proof) => proof, + Err(result) => return result, + }; + let shielded_address = read_shielded_address(shielded_address); + + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(pair) => pair, + Err(result) => return result, + }; + let seed = match resolve_seed_from_resolver(mnemonic_resolver_handle, &wallet_id) { + Ok(seed) => seed, + Err(result) => return result, + }; + // SAFETY: the caller pins the resolver handle for the duration of this + // call. + let crypto = unsafe { + resolver_contact_crypto_provider(mnemonic_resolver_handle, wallet_id, wallet.network()) + }; + // The signer pointer crosses into the worker as a plain address and is + // re-materialized there, as the public send does. + let signer_addr = signer_handle as usize; + + // The proof runs on a worker thread: Halo 2 synthesis recurses past the + // iOS dispatch-thread stack. + let result = block_on_worker(async move { + // SAFETY: the caller pins the signer for the duration of this call, + // and `block_on_worker` blocks this frame until the task completes. + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let prover = CachedOrchardProver::new(); + let result = wallet + .send_private_contact_request( + &coordinator, + seed.as_ref(), + account, + &sender, + &recipient, + label, + AutoAcceptProofSource::from_option(proof), + shielded_address, + signer, + &crypto, + &prover, + ) + .await; + poke_sync_on_unconfirmed(&result, handle); + result + }); + match result { + Ok(request) => { + unsafe { *out_request_handle = CONTACT_REQUEST_STORAGE.insert(request) }; + PlatformWalletFFIResult::ok() + } + Err(error) => seed_mismatch_result(error) + .unwrap_or_else(|error| map_spend_result(Err(error), "private contact request")), + } +} + +/// Accept a DashPay contact request that came privately, replying privately +/// and paying for the reply from `account`'s shielded notes, as +/// [`platform_wallet_manager_send_private_contact_request`] pays. +/// `shielded_address` is the Orchard address the contact may pay us at +/// privately; null omits it. +/// +/// `request_handle` is a live `CONTACT_REQUEST_STORAGE` handle of the +/// incoming request. `signer_handle` is the identity signer the public accept +/// takes (`platform_wallet_accept_contact_request_with_signer`); it signs the +/// reply. Writes a handle into `ESTABLISHED_CONTACT_STORAGE` to +/// `out_established_handle` (release with `established_contact_destroy`). +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `mnemonic_resolver_handle` must be a valid, non-destroyed handle; the +/// caller retains ownership and pins it for the call. +/// - `shielded_address`, when non-null, must point to 43 readable bytes. +/// - `signer_handle` must be a valid, non-destroyed `*mut SignerHandle` +/// produced by `dash_sdk_signer_create_with_ctx`; the caller retains +/// ownership and pins it for the call. +/// - `out_established_handle` must be a valid, writable pointer. +#[cfg(feature = "shielded")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_accept_private_contact_request( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + request_handle: Handle, + shielded_address: *const u8, + signer_handle: *mut SignerHandle, + out_established_handle: *mut Handle, +) -> PlatformWalletFFIResult { + // Same panic contract as the send: the reply's notes may be spent. + catch_spend_panic("private contact request accept", || { + accept_private_contact_request_inner( + handle, + wallet_id_bytes, + mnemonic_resolver_handle, + account, + request_handle, + shielded_address, + signer_handle, + out_established_handle, + ) + }) +} + +/// Body of [`platform_wallet_manager_accept_private_contact_request`], as an +/// ordinary Rust function so a panic unwinds into [`catch_spend_panic`]. +/// +/// # Safety +/// Identical contract to the export that calls it. +#[cfg(feature = "shielded")] +#[allow(clippy::too_many_arguments)] +unsafe fn accept_private_contact_request_inner( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + request_handle: Handle, + shielded_address: *const u8, + signer_handle: *mut SignerHandle, + out_established_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(signer_handle); + check_ptr!(out_established_handle); + unsafe { *out_established_handle = NULL_HANDLE }; + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let request = CONTACT_REQUEST_STORAGE.with_item(request_handle, |request| request.clone()); + let request = unwrap_option_or_return!(request); + let shielded_address = read_shielded_address(shielded_address); + + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(pair) => pair, + Err(result) => return result, + }; + let seed = match resolve_seed_from_resolver(mnemonic_resolver_handle, &wallet_id) { + Ok(seed) => seed, + Err(result) => return result, + }; + // SAFETY: the caller pins the resolver handle for the duration of this + // call. + let crypto = unsafe { + resolver_contact_crypto_provider(mnemonic_resolver_handle, wallet_id, wallet.network()) + }; + let signer_addr = signer_handle as usize; + + let result = block_on_worker(async move { + // SAFETY: as in the send, the caller pins the signer for the call. + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let prover = CachedOrchardProver::new(); + let result = wallet + .accept_private_contact_request( + &coordinator, + seed.as_ref(), + account, + &request, + shielded_address, + signer, + &crypto, + &prover, + ) + .await; + poke_sync_on_unconfirmed(&result, handle); + result + }); + match result { + Ok(contact) => { + unsafe { *out_established_handle = ESTABLISHED_CONTACT_STORAGE.insert(contact) }; + PlatformWalletFFIResult::ok() + } + Err(error) => seed_mismatch_result(error) + .unwrap_or_else(|error| map_spend_result(Err(error), "private contact request accept")), + } +} + +/// Accept a DashPay contact request replying the way it came +/// (`accept_contact_request_replying_in_kind`): privately, paid from +/// `account`'s shielded notes, when it came privately and no reply exists +/// yet; otherwise with a `contactRequest` document signed by `signer_handle`, +/// or by adopting the reply already sent. Which reply a request takes is the +/// wallet's decision: this is the one accept a host calls. `shielded_address` +/// is the Orchard address the contact may pay us at privately; null omits it, +/// and a public reply ignores it. Exported in every build. +/// +/// Only a private reply needs shielded support and the spend authority: the +/// shielded coordinator is looked up, and the mnemonic read through +/// `mnemonic_resolver_handle`, when the wallet asks for the payment. A public +/// or adopted reply works on a manager without +/// `platform_wallet_manager_configure_shielded` and reads no mnemonic for the +/// payment (the DashPay keys still come from the resolver). In a build +/// without the `shielded` feature a request that needs a private reply fails +/// with `ErrorShieldedBuildRequired`, and every other accept works as in a +/// shielded build. +/// +/// Errors: a public or adopted reply fails with the codes the public accept +/// returns. Once a private reply's payment is asked for, errors follow the +/// shielded spend contract: `ErrorShieldedSpendUnconfirmed` means the reply +/// may already be on chain; do not accept again. A failed payment lookup +/// returns the code the lookup reported. +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `mnemonic_resolver_handle` must be a valid, non-destroyed handle; the +/// caller retains ownership and pins it for the call. +/// - `shielded_address`, when non-null, must point to 43 readable bytes. +/// - `signer_handle` must be a valid, non-destroyed `*mut SignerHandle` +/// produced by `dash_sdk_signer_create_with_ctx`; the caller retains +/// ownership and pins it for the call. +/// - `out_established_handle` must be a valid, writable pointer. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_accept_contact_request_replying_in_kind( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + request_handle: Handle, + shielded_address: *const u8, + signer_handle: *mut SignerHandle, + out_established_handle: *mut Handle, +) -> PlatformWalletFFIResult { + let accept = || { + accept_contact_request_replying_in_kind_inner( + handle, + wallet_id_bytes, + mnemonic_resolver_handle, + account, + request_handle, + shielded_address, + signer_handle, + out_established_handle, + ) + }; + // Same panic contract as the private accept: a private reply's notes may + // be spent when it strikes. A build without shielded support spends none. + #[cfg(feature = "shielded")] + let result = catch_spend_panic("contact request accept", accept); + #[cfg(not(feature = "shielded"))] + let result = accept(); + result +} + +/// Why a private reply could not be paid for, as the FFI reports it: the +/// export returns this code and message rather than the wallet error the +/// payment hands back to the wallet. Kept as owned parts, since a result +/// cannot cross threads. +type PaymentFailure = (PlatformWalletFFIResultCode, String); + +/// The parts of a failed FFI `result`; its message is freed with it. +#[cfg(feature = "shielded")] +fn payment_failure(result: PlatformWalletFFIResult) -> PaymentFailure { + let message = if result.message.is_null() { + String::new() + } else { + // SAFETY: a non-null message is a NUL-terminated string the FFI made. + unsafe { CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned() + }; + (result.code, message) +} + +/// Record `failure` for the export to return, and the wallet error the +/// payment hands back to the wallet in its place. +fn record_payment_failure( + slot: &Mutex>, + failure: PaymentFailure, +) -> PlatformWalletError { + let error = PlatformWalletError::InvalidParameter(failure.1.clone()); + if let Ok(mut recorded) = slot.lock() { + *recorded = Some(failure); + } + error +} + +/// The wallet of `wallet_id` under the manager `handle`, without the +/// shielded coordinator, which only a private reply needs. Wait-free (the +/// manager's wallet map is read without a lock or the runtime), with the +/// errors `resolve_wallet_and_coordinator` reports. +fn resolve_manager_wallet( + handle: Handle, + wallet_id: &[u8; 32], +) -> Result, PlatformWalletFFIResult> { + match PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |manager| manager.get_wallet_blocking(wallet_id)) + { + None => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + format!("invalid manager handle: {handle}"), + )), + Some(None) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("wallet not found: {}", hex::encode(wallet_id)), + )), + Some(Some(wallet)) => Ok(wallet), + } +} + +/// What a private reply is paid with, looked up only when the wallet asks: +/// the shielded coordinator, then the seed read through the resolver at +/// `resolver_addr`. Runs on a blocking thread (the coordinator lookup blocks +/// on the runtime, and the resolver calls into the host). +/// +/// # Safety +/// `resolver_addr` must be the address of a valid `MnemonicResolverHandle` +/// that the export's caller pins for the call. +#[cfg(feature = "shielded")] +unsafe fn private_reply_payment_parts( + handle: Handle, + wallet_id: [u8; 32], + resolver_addr: usize, +) -> Result<(Arc, Zeroizing<[u8; 64]>), PaymentFailure> { + let coordinator = resolve_shielded_coordinator(handle).map_err(payment_failure)?; + let seed = resolve_seed_from_resolver(resolver_addr as *mut MnemonicResolverHandle, &wallet_id) + .map_err(payment_failure)?; + Ok((coordinator, seed)) +} + +/// Body of [`platform_wallet_manager_accept_contact_request_replying_in_kind`], +/// as an ordinary Rust function so a panic unwinds into `catch_spend_panic` +/// in a shielded build. +/// +/// # Safety +/// Identical contract to the export that calls it. +#[allow(clippy::too_many_arguments)] +unsafe fn accept_contact_request_replying_in_kind_inner( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + request_handle: Handle, + shielded_address: *const u8, + signer_handle: *mut SignerHandle, + out_established_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(signer_handle); + check_ptr!(out_established_handle); + unsafe { *out_established_handle = NULL_HANDLE }; + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let request = CONTACT_REQUEST_STORAGE.with_item(request_handle, |request| request.clone()); + let request = unwrap_option_or_return!(request); + let shielded_address = read_shielded_address(shielded_address); + + let wallet = match resolve_manager_wallet(handle, &wallet_id) { + Ok(wallet) => wallet, + Err(result) => return result, + }; + // SAFETY: the caller pins the resolver handle for the duration of this + // call. + let crypto = unsafe { + resolver_contact_crypto_provider(mnemonic_resolver_handle, wallet_id, wallet.network()) + }; + let signer_addr = signer_handle as usize; + // Why paying for a private reply failed, when it did: the export returns + // that, not the wallet error standing in for it. + let payment_failure_slot: Arc>> = Arc::new(Mutex::new(None)); + let failure_slot = Arc::clone(&payment_failure_slot); + + #[cfg(feature = "shielded")] + let payment_asked = Arc::new(AtomicBool::new(false)); + #[cfg(feature = "shielded")] + let result = { + let asked = Arc::clone(&payment_asked); + let resolver_addr = mnemonic_resolver_handle as usize; + block_on_worker(async move { + // SAFETY: as in the private accept, the caller pins the signer for + // the call. + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let prover = CachedOrchardProver::new(); + let result = wallet + .accept_contact_request_replying_in_kind( + &request, + shielded_address, + signer, + &crypto, + || async { + asked.store(true, Ordering::SeqCst); + let parts = tokio::task::spawn_blocking(move || { + // SAFETY: the caller pins the resolver for the call, + // and this export blocks until the task completes. + unsafe { private_reply_payment_parts(handle, wallet_id, resolver_addr) } + }) + .await + .unwrap_or_else(|_| { + Err(( + PlatformWalletFFIResultCode::ErrorUnknown, + "the private reply's payment lookup panicked".to_string(), + )) + }); + match parts { + Ok((coordinator, seed)) => Ok(PrivateReplyPayment { + coordinator, + seed, + account, + prover: &prover, + }), + Err(failure) => Err(record_payment_failure(&failure_slot, failure)), + } + }, + ) + .await; + poke_sync_on_unconfirmed(&result, handle); + result + }) + }; + // Without shielded support a private reply cannot be paid for; every + // other reply is the same as in a shielded build. + #[cfg(not(feature = "shielded"))] + let result = { + let _ = account; + let identity = wallet.identity().clone(); + block_on_worker(async move { + // SAFETY: the caller pins the signer for the call. + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity + .dashpay() + .accept_contact_request_replying_in_kind( + &request, + shielded_address, + signer, + &crypto, + // Refused before any payment could reach the network, + // so the payment is never marked as started. + |_entry, _started| async move { + Err::(record_payment_failure( + &failure_slot, + ( + PlatformWalletFFIResultCode::ErrorShieldedBuildRequired, + "this contact request needs a private reply, which is paid \ + from the shielded pool; this build has no shielded support" + .to_string(), + ), + )) + }, + ) + .await + }) + }; + + match result { + Ok(contact) => { + unsafe { *out_established_handle = ESTABLISHED_CONTACT_STORAGE.insert(contact) }; + PlatformWalletFFIResult::ok() + } + Err(error) => { + if let Some((code, message)) = payment_failure_slot + .lock() + .ok() + .and_then(|mut slot| slot.take()) + { + return PlatformWalletFFIResult::err(code, message); + } + let error = match seed_mismatch_result(error) { + Ok(result) => return result, + Err(error) => error, + }; + #[cfg(feature = "shielded")] + if payment_asked.load(Ordering::SeqCst) { + return map_spend_result(Err(error), "contact request accept"); + } + // No payment was asked for, so nothing was spent: report the + // reply's failure as the public accept does. + PlatformWalletFFIResult::from(error) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A wrong seed refused by the private sync, or by the store read a send + /// or accept makes first, takes the code every DashPay export gives it; + /// any other error is left to the export's own mapping. + #[test] + fn should_report_a_seed_mismatch_from_the_private_store_by_its_dashpay_code() { + let result = seed_mismatch_result(PlatformWalletError::SeedMismatch { + wallet_id: "ab".to_string(), + }) + .expect("a seed mismatch is mapped here"); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + assert!(matches!( + seed_mismatch_result(PlatformWalletError::PrivateContactPaymentsNotRestored), + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + )); + } + + #[test] + fn should_clear_both_sync_outputs_on_an_unknown_wallet() { + let mut requests = ContactRequestHandleArray { + handles: std::ptr::NonNull::dangling().as_ptr(), + count: 3, + }; + let mut report = PrivateContactSyncReportFFI { + next_position: 9, + pages_read: 9, + rejected_count: 9, + unpersisted_identity_count: 9, + complete: true, + }; + // A resolver is never called for an unknown wallet; any non-null + // pointer passes the null check. + let resolver = std::ptr::NonNull::::dangling().as_ptr(); + let result = unsafe { + platform_wallet_sync_private_contact_requests( + 0xDEAD_BEEF, + resolver, + 1, + &mut requests, + &mut report, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound); + assert!(requests.handles.is_null()); + assert_eq!(requests.count, 0); + assert_eq!(report, PrivateContactSyncReportFFI::default()); + } + + #[test] + fn should_reject_a_null_resolver_on_sync() { + let mut requests = ContactRequestHandleArray::empty(); + let mut report = PrivateContactSyncReportFFI::default(); + let result = unsafe { + platform_wallet_sync_private_contact_requests( + 0, + std::ptr::null_mut(), + 1, + &mut requests, + &mut report, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// The request is signed by the identity signer, so the private send + /// refuses to run without one, before it touches the wallet. + #[cfg(feature = "shielded")] + #[test] + fn should_reject_a_null_identity_signer_on_the_private_send() { + let wallet_id = [1u8; 32]; + let sender = [2u8; 32]; + let recipient = [3u8; 32]; + let resolver = std::ptr::NonNull::::dangling().as_ptr(); + let mut out = 0xDEAD_BEEF; + let result = unsafe { + platform_wallet_manager_send_private_contact_request( + 0xDEAD_BEEF, + wallet_id.as_ptr(), + resolver, + 0, + sender.as_ptr(), + recipient.as_ptr(), + std::ptr::null(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// As the private send: the reply is signed by the identity signer. + #[cfg(feature = "shielded")] + #[test] + fn should_reject_a_null_identity_signer_on_the_private_accept() { + let wallet_id = [1u8; 32]; + let resolver = std::ptr::NonNull::::dangling().as_ptr(); + let mut out = 0xDEAD_BEEF; + let result = unsafe { + platform_wallet_manager_accept_private_contact_request( + 0xDEAD_BEEF, + wallet_id.as_ptr(), + resolver, + 0, + 0xDEAD_BEEF, + std::ptr::null(), + std::ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// As the private accept: whichever reply the wallet picks is signed by + /// the identity signer. + #[test] + fn should_reject_a_null_identity_signer_on_the_accept_replying_in_kind() { + let wallet_id = [1u8; 32]; + let resolver = std::ptr::NonNull::::dangling().as_ptr(); + let mut out = 0xDEAD_BEEF; + let result = unsafe { + platform_wallet_manager_accept_contact_request_replying_in_kind( + 0xDEAD_BEEF, + wallet_id.as_ptr(), + resolver, + 0, + 0xDEAD_BEEF, + std::ptr::null(), + std::ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// A request that did not come privately is accepted with a document, which + /// needs neither shielded support nor the spend authority: on a manager + /// whose shielded support was never configured (and in a build without + /// it), the accept gets past the lookups to the wallet's own refusal (the + /// recipient is not one of its identities, reported as the public accept + /// reports it), and the resolver is never asked for the mnemonic. + #[test] + fn should_accept_publicly_without_shielded_support_or_a_mnemonic_read() { + use crate::contact_request::contact_request_destroy; + use crate::event_handler::EventHandlerCallbacks; + use crate::manager::{ + platform_wallet_manager_create, platform_wallet_manager_create_wallet_from_mnemonic, + platform_wallet_manager_destroy, + }; + use crate::persistence::PersistenceCallbacks; + use platform_wallet::ContactRequest; + use rs_sdk_ffi::{ + dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy, + dash_sdk_signer_create, dash_sdk_signer_destroy, mnemonic_resolver_result, + SignCompletionCallback, + }; + use std::ffi::{c_void, CStr}; + use std::os::raw::c_char; + use std::sync::atomic::{AtomicUsize, Ordering}; + + unsafe extern "C" fn begin(_: *mut c_void, _: *const u8) -> i32 { + 0 + } + unsafe extern "C" fn end(_: *mut c_void, _: *const u8, _: bool) -> i32 { + 0 + } + unsafe extern "C" fn counting_resolve( + ctx: *const c_void, + _wallet_id: *const u8, + _out: *mut c_char, + _capacity: usize, + _out_len: *mut usize, + ) -> i32 { + (*(ctx as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); + mnemonic_resolver_result::NOT_FOUND + } + unsafe extern "C" fn noop_destroy(_ctx: *mut c_void) {} + // A public reply to this request is refused before anything is signed; + // a signature request would fail rather than hang. + unsafe extern "C" fn refuse_to_sign( + _signer: *const c_void, + _pubkey: *const u8, + _pubkey_len: usize, + _key_type: u8, + _data: *const u8, + _data_len: usize, + completion_ctx: *mut c_void, + completion: SignCompletionCallback, + ) { + completion( + completion_ctx, + std::ptr::null(), + 0, + 1, + c"no key in this test".as_ptr(), + ); + } + unsafe extern "C" fn cannot_sign( + _signer: *const c_void, + _pubkey: *const u8, + _pubkey_len: usize, + _key_type: u8, + ) -> bool { + false + } + + let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); + let persistence = PersistenceCallbacks { + on_changeset_begin_fn: Some(begin), + on_changeset_end_fn: Some(end), + ..Default::default() + }; + let events = EventHandlerCallbacks { + context: std::ptr::null_mut(), + on_wallet_event_fn: None, + on_error_fn: None, + on_platform_address_sync_completed_fn: None, + on_shielded_sync_completed_fn: None, + on_shielded_sync_progress_fn: None, + on_shielded_tree_progress_fn: None, + release_fn: None, + }; + let mut manager = NULL_HANDLE; + let created = unsafe { + platform_wallet_manager_create( + &sdk as *const dash_sdk::Sdk as *const c_void, + &persistence, + &events, + &mut manager, + ) + }; + assert_eq!(created.code, PlatformWalletFFIResultCode::Success); + + let mnemonic = c"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let mut wallet_handle = NULL_HANDLE; + let mut wallet_id = [0u8; 32]; + let added = unsafe { + platform_wallet_manager_create_wallet_from_mnemonic( + manager, + mnemonic.as_ptr(), + FFINetwork::Testnet, + 0, + &mut wallet_handle, + &mut wallet_id, + ) + }; + assert_eq!( + added.code, + PlatformWalletFFIResultCode::Success, + "the wallet is added" + ); + + // A request to an identity the wallet does not manage. + let request = ContactRequest::new( + [0xA0; 32].into(), + [0xB0; 32].into(), + 1, + 2, + 7, + vec![0x33; 96], + 100, + 1_700_000_000_000, + ); + let request_handle = CONTACT_REQUEST_STORAGE.insert(request); + let reads = AtomicUsize::new(0); + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create( + &reads as *const AtomicUsize as *mut c_void, + counting_resolve, + noop_destroy, + ) + }; + let signer = unsafe { dash_sdk_signer_create(refuse_to_sign, cannot_sign, None) }; + + let mut established = 0xDEAD_BEEF; + let result = unsafe { + platform_wallet_manager_accept_contact_request_replying_in_kind( + manager, + wallet_id.as_ptr(), + resolver, + 0, + request_handle, + std::ptr::null(), + signer, + &mut established, + ) + }; + // Exactly what the public accept reports for this refusal (the + // wallet's `IdentityNotFound`), not the missing-coordinator error an + // up-front lookup gave. + let expected = + PlatformWalletFFIResult::from(PlatformWalletError::IdentityNotFound([0xB0; 32].into())); + let message_of = |result: &PlatformWalletFFIResult| { + if result.message.is_null() { + String::new() + } else { + unsafe { CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned() + } + }; + assert_eq!(result.code, expected.code, "{}", message_of(&result)); + assert_eq!(message_of(&result), message_of(&expected)); + assert_eq!(established, NULL_HANDLE); + assert_eq!(reads.load(Ordering::SeqCst), 0, "no mnemonic was read"); + + let _ = contact_request_destroy(request_handle); + unsafe { + dash_sdk_signer_destroy(signer); + dash_sdk_mnemonic_resolver_destroy(resolver); + } + let _ = unsafe { platform_wallet_manager_destroy(manager) }; + } + + /// A managed identity with no private request in flight reports none; an + /// unknown handle is not found and leaves the flag false; a null output is + /// refused. Reserving a reply is the wallet's own (its tests pin the + /// reservation), so this pins the read. + #[test] + fn should_read_whether_a_private_reply_is_pending() { + use dpp::identity::{Identity, IdentityV0}; + use platform_wallet::ManagedIdentity; + + let identity = Identity::V0(IdentityV0 { + id: [0xB0; 32].into(), + public_keys: Default::default(), + balance: 0, + revision: 0, + }); + let handle = MANAGED_IDENTITY_STORAGE.insert(ManagedIdentity::new(identity, 0)); + let contact_id = [0xA0u8; 32]; + + let mut pending = true; + let result = unsafe { + managed_identity_get_private_reply_pending(handle, contact_id.as_ptr(), &mut pending) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(!pending, "nothing is in flight to the contact"); + + let mut pending = true; + let result = unsafe { + managed_identity_get_private_reply_pending( + 0xDEAD_BEEF, + contact_id.as_ptr(), + &mut pending, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound); + assert!(!pending); + + let result = unsafe { + managed_identity_get_private_reply_pending( + handle, + contact_id.as_ptr(), + std::ptr::null_mut(), + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + let _ = MANAGED_IDENTITY_STORAGE.remove(handle); + } + + #[test] + fn should_carry_every_field_of_a_private_contact() { + let address = [0x55; SHIELDED_ADDRESS_LEN]; + let with_address = PrivateContactFFI::from(PrivateContact { + received_privately: true, + sent_privately: false, + shielded_address: Some(address), + }); + assert!(with_address.received_privately); + assert!(!with_address.sent_privately); + assert!(with_address.has_shielded_address); + assert_eq!(with_address.shielded_address, address); + + let without_address = PrivateContactFFI::from(PrivateContact { + received_privately: false, + sent_privately: true, + shielded_address: None, + }); + assert!(without_address.sent_privately); + assert!(!without_address.has_shielded_address); + assert_eq!(without_address.shielded_address, [0; SHIELDED_ADDRESS_LEN]); + } + + #[test] + fn should_report_no_private_contact_for_an_unknown_identity_handle() { + let mut contact = PrivateContactFFI { + received_privately: true, + sent_privately: true, + has_shielded_address: true, + shielded_address: [0x55; SHIELDED_ADDRESS_LEN], + }; + let contact_id = [7u8; 32]; + let result = unsafe { + managed_identity_get_private_contact(0xDEAD_BEEF, contact_id.as_ptr(), &mut contact) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!(contact, PrivateContactFFI::empty()); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 04ccfc420d2..b3aa4884c25 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -563,7 +563,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( /// prune backstop); the pass outcome is logged, not surfaced. /// /// [`ShieldedSyncManager::sync_now`]: platform_wallet::manager::shielded_sync::ShieldedSyncManager::sync_now -fn poke_sync_on_unconfirmed(result: &Result, handle: Handle) { +pub(crate) fn poke_sync_on_unconfirmed(result: &Result, handle: Handle) { let ambiguous = matches!( result, Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) @@ -622,11 +622,18 @@ pub(crate) fn map_spend_result( | Err(e @ PlatformWalletError::ShieldedRecoveryKeysRequired { .. }) => e.into(), // A private contact request in flight, or paid and not yet read back // from the store: nothing new was paid, and a later private sync - // settles it, so it keeps its own pending code. A wallet removed - // before the payment started keeps the lifecycle refusal code - // (`NotFound`) every other export gives it. + // settles it, so it keeps its own pending code; unrestored private + // payments keep theirs. A wallet removed before the payment started keeps + // the lifecycle refusal code (`NotFound`) every other export gives + // it. A protocol-version refusal from the store read before any + // payment keeps the blanket conversion's split: a lasting refusal + // is `ErrorProtocolVersionNotActive`, a transient one the catch-all. Err(e @ PlatformWalletError::PrivateContactRequestPending(..)) - | Err(e @ PlatformWalletError::WalletRemovedBeforeSend(..)) => e.into(), + | Err(e @ PlatformWalletError::PrivateContactPaymentsNotRestored) + | Err(e @ PlatformWalletError::WalletRemovedBeforeSend(..)) + | Err(e @ PlatformWalletError::Sdk(dash_sdk::Error::ProtocolVersionNotActive { .. })) => { + e.into() + } // Retryable: the wallet couldn't build the spend against any // Platform-recorded anchor yet (its commitment tree is mid-block after // an index-chunk sync). Nothing was broadcast and the notes were @@ -750,7 +757,7 @@ const SPEND_PANIC_GUIDANCE: &str = "The spend may or may not have been broadcast /// transition was broadcast, so the outcome is genuinely ambiguous. That code's contract is /// exactly the conservative one this needs — the host must not auto-retry, the reservation stays /// in place, and the next nullifier sync (or an app restart) reconciles whether the spend landed. -fn catch_spend_panic( +pub(crate) fn catch_spend_panic( operation: &str, body: impl FnOnce() -> PlatformWalletFFIResult, ) -> PlatformWalletFFIResult { @@ -2334,6 +2341,30 @@ pub(crate) fn resolve_wallet_and_coordinator( Ok((wallet, coordinator)) } +/// The coordinator half of [`resolve_wallet_and_coordinator`], with the same +/// errors. It blocks on the runtime, so it must not run on one of its async +/// workers: call it from the export's thread or from `spawn_blocking`. +pub(crate) fn resolve_shielded_coordinator( + handle: Handle, +) -> Result< + std::sync::Arc, + PlatformWalletFFIResult, +> { + match PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(manager.shielded_coordinator()) + }) { + None => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + format!("invalid manager handle: {handle}"), + )), + Some(None) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "shielded support not configured: call platform_wallet_manager_configure_shielded first", + )), + Some(Some(coordinator)) => Ok(coordinator), + } +} + #[cfg(test)] mod tests { use super::*; @@ -2999,6 +3030,46 @@ mod tests { assert!(message_of(&result).contains("this request was not started")); } + /// A protocol-version refusal from the store read a private send or + /// accept makes before paying keeps the blanket conversion's split: a + /// lasting refusal is code 70, a transient one the catch-all. + #[test] + fn map_spend_result_keeps_a_protocol_version_refusal_typed() { + let refused = |cause: Option>| { + map_spend_result( + Err(PlatformWalletError::Sdk( + dash_sdk::Error::ProtocolVersionNotActive { + method: "get_private_document_entries", + required: 15, + feature: None, + network: 14, + pinned: false, + cause, + }, + )), + "private contact request", + ) + }; + let lasting = refused(None); + assert_eq!( + lasting.code, + PlatformWalletFFIResultCode::ErrorProtocolVersionNotActive + ); + assert_eq!(lasting.code as i32, 70); + let transient = refused(Some(Box::new(dash_sdk::Error::Generic( + "no node answered".to_string(), + )))); + assert_eq!(transient.code, PlatformWalletFFIResultCode::ErrorUnknown); + assert_eq!( + map_spend_result( + Err(PlatformWalletError::PrivateContactPaymentsNotRestored), + "private contact request", + ) + .code, + PlatformWalletFFIResultCode::ErrorPrivateContactPaymentsNotRestored + ); + } + /// A private payment refused because its wallet was removed first keeps /// the lifecycle refusal code, not the generic operation failure. #[test] diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index d9b6170b7c7..bc9c7f29453 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -30,7 +30,8 @@ discovering them afterwards as failed opens. The route decision is logged as `store_migration_route` with the validated `source_version`, `source_checksum` and one of `new-store`, `accepted-v1-to-v3`, `historical-v2-to-v3`, `previous-live-v2-current-shape`, -`unsupported-v2`, `labelled-current-v3`, `ordinary-current-plan` or, from the +`unsupported-v2`, `published-v3-to-v4`, `published-v4-to-v5`, +`labelled-current-v5`, `ordinary-current-plan` or, from the bridge, `legacy-v1-bridge-to-v3`. Resolving a frozen schema's identity builds a temporary store, so it is memoized per process and consulted only where a label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` label takes the diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ba455874d35..b95aa685274 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -94,13 +94,15 @@ public enum DashModelContainer { PersistentInvitation.self, PersistentMasternode.self, PersistentTrackedMasternode.self, - PersistentIdentityBalanceMetadata.self + PersistentIdentityBalanceMetadata.self, + PersistentPrivateContactRequestsPosition.self, + PersistentPrivateShieldedAddress.self ] } /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV4.self) + Schema(versionedSchema: DashSchemaV5.self) } /// Create a persistent model container for storing data. @@ -172,7 +174,7 @@ public enum DashModelContainer { bridgeLegacyStore: Bool = true ) throws -> ModelContainer { SDKLogger.event("store_open_started", category: .persistence, - fields: ["target_version": .publicText("4.0.0")]) + fields: ["target_version": .publicText("5.0.0")]) do { let container: ModelContainer if bridgeLegacyStore { @@ -185,7 +187,7 @@ public enum DashModelContainer { configurations: [configuration]) } SDKLogger.event("store_open_succeeded", category: .persistence, - fields: ["target_version": .publicText("4.0.0")]) + fields: ["target_version": .publicText("5.0.0")]) return container } catch { logMigrationFailure(error) @@ -201,7 +203,7 @@ public enum DashModelContainer { let domain = systemDomains.contains(nsError.domain) ? nsError.domain : String(reflecting: type(of: error)) SDKLogger.event("store_open_failed", category: .persistence, severity: .error, fields: ["error_domain": .publicText(domain), "error_code": .integer(Int64(nsError.code)), - "target_version": .publicText("4.0.0")]) + "target_version": .publicText("5.0.0")]) } /// Select by the complete stored model identity, after journal recovery. @@ -218,7 +220,7 @@ public enum DashModelContainer { guard ObjectIdentifier(defaultPlan) == ObjectIdentifier(DashMigrationPlan.self) else { return defaultPlan } guard FileManager.default.fileExists(atPath: url.path) else { SDKLogger.event("store_migration_route", category: .persistence, fields: [ - "route": .publicText("new-store"), "target_version": .publicText("4.0.0")]) + "route": .publicText("new-store"), "target_version": .publicText("5.0.0")]) return defaultPlan } let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) @@ -236,7 +238,7 @@ public enum DashModelContainer { func logRoute(_ route: String) { SDKLogger.event("store_migration_route", category: .persistence, fields: [ "source_version": .publicText(safeVersions), "source_checksum": .publicText(safeChecksum), - "route": .publicText(route), "target_version": .publicText("4.0.0")]) + "route": .publicText(route), "target_version": .publicText("5.0.0")]) } func matches(_ type: any VersionedSchema.Type) throws -> Bool { let expected = try identity(type) @@ -268,7 +270,9 @@ public enum DashModelContainer { // `source_checksum` above already identifies the exact graph. logRoute("published-v3-to-v4") } else if versions == ["4.0.0"] { - logRoute("labelled-current-v4") + logRoute("published-v4-to-v5") + } else if versions == ["5.0.0"] { + logRoute("labelled-current-v5") } else { logRoute("ordinary-current-plan") } @@ -289,13 +293,14 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] + [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self] } public static var stages: [MigrationStage] { [ .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self) ] } } @@ -303,10 +308,13 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// Separate compatibility route: V1 has fields missing from historical V2. /// Never insert V2 between this baseline and the current schema. enum DashAcceptedV1MigrationPlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self] } + static var schemas: [any VersionedSchema.Type] { + [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self] + } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self)] + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self)] } } @@ -382,8 +390,15 @@ public enum DashSchemaV3: VersionedSchema { public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV3.models } } -/// Current schema: optional asset-lock operation IDs correlate drain recovery. +/// Published V4 (optional asset-lock operation IDs). Its complete model graph is frozen. public enum DashSchemaV4: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } + public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV4.models } +} + +/// Current schema: the private contact request sync's scan state (each +/// identity's read position and its private contacts' shielded addresses). +public enum DashSchemaV5: VersionedSchema { + public static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift new file mode 100644 index 00000000000..789db14d2cb --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +/// Where an identity reads DashPay's private contact request store from +/// next, so a relaunch resumes the private contact request sync instead of +/// reading the store from its start. Written in the same changeset round as +/// the contact requests read before it. +@Model +public final class PersistentPrivateContactRequestsPosition { + #Unique([\.networkRaw, \.walletId, \.identityId]) + public var networkRaw: UInt32 + public var walletId: Data + public var identityId: Data + // Bit pattern preserves the full unsigned FFI domain in a SQLite integer. + public var position: Int64 + /// The fingerprint of the scanning keys `position` holds for; opaque + /// here, handed back to the native wallet as it came. + public var keyFingerprint: Data + /// Whether a private contact request the identity paid for may still be + /// unresolved; handed back as it came. Loaded set, Rust holds the + /// identity's public contact requests until its private payments are + /// restored from the shielded store. + public var privatePaymentsUnresolved: Bool = false + + public init(networkRaw: UInt32, walletId: Data, identityId: Data, + position: UInt64, keyFingerprint: Data, privatePaymentsUnresolved: Bool = false) { + self.networkRaw = networkRaw + self.walletId = walletId + self.identityId = identityId + self.position = Int64(bitPattern: position) + self.keyFingerprint = keyFingerprint + self.privatePaymentsUnresolved = privatePaymentsUnresolved + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift new file mode 100644 index 00000000000..6468811ed80 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift @@ -0,0 +1,26 @@ +import Foundation +import SwiftData + +/// The shielded address a private contact's tracked request to one of the +/// wallet's identities carries, kept so a relaunch has it without reading +/// the private contact request store again. +@Model +public final class PersistentPrivateShieldedAddress { + #Unique([\.networkRaw, \.walletId, \.ownerId, \.contactId]) + public var networkRaw: UInt32 + public var walletId: Data + /// The wallet-owned identity the request is to. + public var ownerId: Data + /// The contact who sent the request. + public var contactId: Data + /// The raw 43-byte Orchard payment address. + public var address: Data + + public init(networkRaw: UInt32, walletId: Data, ownerId: Data, contactId: Data, address: Data) { + self.networkRaw = networkRaw + self.walletId = walletId + self.ownerId = ownerId + self.contactId = contactId + self.address = address + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 63b20689d2c..cf24ecf0968 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -109,6 +109,14 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { public static let dashpayPayments: UInt64 = 1 << 12 /// Durable operation UUIDs, including consumed asset-lock tombstones. public static let assetLockOperationIds: UInt64 = 1 << 13 + /// The private contact request sync's scan state (each identity's read + /// position in DashPay's private contact request store, and its private + /// contacts' shielded addresses) is stored on its round and restored at + /// load, so a relaunch resumes the sync where it stopped. Mirrors + /// `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE`; Rust + /// only honours the declaration when the extension carries the three + /// scan-state callbacks. + public static let privateContactRequestScanState: UInt64 = 1 << 14 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index f1d499682d7..53040d476ed 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3257,6 +3257,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.coreSweepRemoval | PlatformWalletPersistenceCapabilities.dashpayPayments | PlatformWalletPersistenceCapabilities.assetLockOperationIds + | PlatformWalletPersistenceCapabilities.privateContactRequestScanState ) } @@ -3296,6 +3297,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_load_identity_balance_block_time_fn = loadIdentityBalanceBlockTimeCallback extensionCallbacks.on_persist_asset_lock_operation_id_fn = persistAssetLockOperationIdCallback extensionCallbacks.on_load_asset_lock_operation_id_fn = loadAssetLockOperationIdCallback + extensionCallbacks.on_persist_private_contact_scan_state_fn = persistPrivateContactScanStateCallback + extensionCallbacks.on_load_private_contact_scan_state_fn = loadPrivateContactScanStateCallback + extensionCallbacks.on_load_private_contact_scan_state_free_fn = + loadPrivateContactScanStateFreeCallback return extensionCallbacks } @@ -5114,6 +5119,138 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // MARK: - Private contact request scan state (additive persistence extension) + + /// One identity's read position in DashPay's private contact request store. + struct PrivateContactRequestsPositionRow: Equatable { + let identityId: Data + let position: UInt64 + /// Opaque to this side: stored and handed back as it came. + let keyFingerprint: Data + /// Stored and handed back as it came. + var privatePaymentsUnresolved: Bool = false + } + + /// The shielded address `contactId`'s private request to `ownerId` carries. + struct PrivateShieldedAddressRow: Equatable { + let ownerId: Data + let contactId: Data + let address: Data + } + + /// The `(owner, contact)` pair of a shielded address the round removed. + struct PrivateShieldedAddressKey: Equatable { + let ownerId: Data + let contactId: Data + } + + /// The network the scan state of `walletId` is stored under, as for the + /// identity rows it belongs with. + private func scanStateNetworkRaw(walletId: Data) -> UInt32 { + (self.network ?? walletNetwork(walletId: walletId) ?? .testnet).rawValue + } + + /// Apply a round's scan state. Identities the round removed lose theirs + /// first, so a new position or address in the same round stands. + func persistPrivateContactScanState( + walletId: Data, + positions: [PrivateContactRequestsPositionRow], + addresses: [PrivateShieldedAddressRow], + removedAddresses: [PrivateShieldedAddressKey], + clearedIdentities: [Data] + ) throws { + try onQueue { + guard inChangeset else { + throw PlatformWalletError.walletOperation( + "Private contact request scan state requires a changeset") + } + let networkRaw = scanStateNetworkRaw(walletId: walletId) + for identityId in clearedIdentities { + for row in try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId && $0.identityId == identityId + })) { + backgroundContext.delete(row) + } + for row in try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId && $0.ownerId == identityId + })) { + backgroundContext.delete(row) + } + } + for row in positions { + let identityId = row.identityId + let existing = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId && $0.identityId == identityId + })).first + if let existing { + existing.position = Int64(bitPattern: row.position) + existing.keyFingerprint = row.keyFingerprint + existing.privatePaymentsUnresolved = row.privatePaymentsUnresolved + } else { + backgroundContext.insert(PersistentPrivateContactRequestsPosition( + networkRaw: networkRaw, walletId: walletId, identityId: identityId, + position: row.position, keyFingerprint: row.keyFingerprint, + privatePaymentsUnresolved: row.privatePaymentsUnresolved)) + } + } + for row in addresses { + let ownerId = row.ownerId + let contactId = row.contactId + let existing = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId + && $0.ownerId == ownerId && $0.contactId == contactId + })).first + if let existing { + existing.address = row.address + } else { + backgroundContext.insert(PersistentPrivateShieldedAddress( + networkRaw: networkRaw, walletId: walletId, ownerId: ownerId, + contactId: contactId, address: row.address)) + } + } + for key in removedAddresses { + let ownerId = key.ownerId + let contactId = key.contactId + for row in try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId + && $0.ownerId == ownerId && $0.contactId == contactId + })) { + backgroundContext.delete(row) + } + } + // endChangeset performs the atomic save with the round's contacts. + } + } + + /// The stored scan state of `walletId`. Throws when the store cannot be + /// read, so the load fails rather than reading the store from its start. + func loadPrivateContactScanState(walletId: Data) throws + -> (positions: [PrivateContactRequestsPositionRow], addresses: [PrivateShieldedAddressRow]) { + try onQueue { + let networkRaw = scanStateNetworkRaw(walletId: walletId) + let positions = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.networkRaw == networkRaw && $0.walletId == walletId })) + let addresses = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.networkRaw == networkRaw && $0.walletId == walletId })) + return ( + positions.map { + PrivateContactRequestsPositionRow( + identityId: $0.identityId, position: UInt64(bitPattern: $0.position), + keyFingerprint: $0.keyFingerprint, + privatePaymentsUnresolved: $0.privatePaymentsUnresolved) + }, + addresses.map { + PrivateShieldedAddressRow(ownerId: $0.ownerId, contactId: $0.contactId, address: $0.address) + } + ) + } + } + // MARK: - Identity snapshot structs /// Swift-side snapshot of the Rust `IdentityEntryFFI` with C @@ -6653,6 +6790,29 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { where !claimedNetworks.contains(row.networkRaw) { backgroundContext.delete(row) } + // The private contact request scan state carries the same + // explicit network key, so it is purged by the same rule. + let positions: FetchDescriptor + let addresses: FetchDescriptor + if let raw = metadataNetwork?.rawValue { + positions = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.networkRaw == raw + }) + addresses = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.networkRaw == raw + }) + } else { + positions = FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }) + addresses = FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }) + } + for row in try backgroundContext.fetch(positions) + where !claimedNetworks.contains(row.networkRaw) { + backgroundContext.delete(row) + } + for row in try backgroundContext.fetch(addresses) + where !claimedNetworks.contains(row.networkRaw) { + backgroundContext.delete(row) + } if let walletRow = walletRow { // Wallet → identities is `.nullify`; this delete @@ -11912,6 +12072,124 @@ private func persistIdentityBalanceBlockTimeCallback( } catch { return -1 } } +/// C shim for `on_persist_private_contact_scan_state_fn`. Copies every row +/// before calling the handler, so Rust can drop its arrays on return. +private func persistPrivateContactScanStateCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + positions: UnsafePointer?, positionsCount: UInt, + addresses: UnsafePointer?, addressesCount: UInt, + removedAddresses: UnsafePointer?, removedAddressesCount: UInt, + clearedIdentities: UnsafePointer?, clearedIdentitiesCount: UInt +) -> Int32 { + guard let context, let walletId else { return -1 } + func rows(_ pointer: UnsafePointer?, _ count: UInt) -> UnsafeBufferPointer? { + guard count > 0 else { return UnsafeBufferPointer(start: nil, count: 0) } + guard let pointer else { return nil } + return UnsafeBufferPointer(start: pointer, count: Int(count)) + } + guard let positionRows = rows(positions, positionsCount), + let addressRows = rows(addresses, addressesCount), + let removedRows = rows(removedAddresses, removedAddressesCount), + let clearedRows = rows(clearedIdentities, clearedIdentitiesCount) else { return -1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.persistPrivateContactScanState( + walletId: Data(bytes: walletId, count: 32), + positions: positionRows.map { + .init(identityId: dataFromTuple32($0.identity_id), position: $0.position, + keyFingerprint: dataFromTuple32($0.key_fingerprint), + privatePaymentsUnresolved: $0.private_payments_unresolved) + }, + addresses: addressRows.map { + var address = $0.address + return .init(ownerId: dataFromTuple32($0.owner_id), contactId: dataFromTuple32($0.contact_id), + address: Swift.withUnsafeBytes(of: &address) { Data($0) }) + }, + removedAddresses: removedRows.map { + .init(ownerId: dataFromTuple32($0.owner_id), contactId: dataFromTuple32($0.contact_id)) + }, + clearedIdentities: clearedRows.map { dataFromTuple32($0) }) + return 0 + } catch { return -1 } +} + +/// C shim for `on_load_private_contact_scan_state_fn`. Hands Rust two +/// arrays of flat rows, released by the free shim below. A stored row whose +/// bytes have the wrong length cannot cross the ABI: the load fails, as for +/// an unreadable store, rather than handing back the rest without it. +private func loadPrivateContactScanStateCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + outPositions: UnsafeMutablePointer?>?, + outPositionsCount: UnsafeMutablePointer?, + outAddresses: UnsafeMutablePointer?>?, + outAddressesCount: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletId, let outPositions, let outPositionsCount, + let outAddresses, let outAddressesCount else { return -1 } + outPositions.pointee = nil + outPositionsCount.pointee = 0 + outAddresses.pointee = nil + outAddressesCount.pointee = 0 + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + let stored: (positions: [PlatformWalletPersistenceHandler.PrivateContactRequestsPositionRow], + addresses: [PlatformWalletPersistenceHandler.PrivateShieldedAddressRow]) + do { + stored = try handler.loadPrivateContactScanState(walletId: Data(bytes: walletId, count: 32)) + } catch { return -1 } + // Every row is checked before either output is allocated. + guard stored.positions.allSatisfy({ $0.identityId.count == 32 && $0.keyFingerprint.count == 32 }), + stored.addresses.allSatisfy({ + $0.ownerId.count == 32 && $0.contactId.count == 32 && $0.address.count == 43 + }) else { return -1 } + let positions = stored.positions + let addresses = stored.addresses + if !positions.isEmpty { + let buffer = UnsafeMutablePointer.allocate(capacity: positions.count) + for (index, row) in positions.enumerated() { + var entry = PrivateContactRequestsPositionFFI() + Swift.withUnsafeMutableBytes(of: &entry.identity_id) { _ = row.identityId.copyBytes(to: $0) } + entry.position = row.position + Swift.withUnsafeMutableBytes(of: &entry.key_fingerprint) { _ = row.keyFingerprint.copyBytes(to: $0) } + entry.private_payments_unresolved = row.privatePaymentsUnresolved + (buffer + index).initialize(to: entry) + } + outPositions.pointee = UnsafePointer(buffer) + outPositionsCount.pointee = UInt(positions.count) + } + if !addresses.isEmpty { + let buffer = UnsafeMutablePointer.allocate(capacity: addresses.count) + for (index, row) in addresses.enumerated() { + var entry = PrivateShieldedAddressFFI() + Swift.withUnsafeMutableBytes(of: &entry.owner_id) { _ = row.ownerId.copyBytes(to: $0) } + Swift.withUnsafeMutableBytes(of: &entry.contact_id) { _ = row.contactId.copyBytes(to: $0) } + Swift.withUnsafeMutableBytes(of: &entry.address) { _ = row.address.copyBytes(to: $0) } + (buffer + index).initialize(to: entry) + } + outAddresses.pointee = UnsafePointer(buffer) + outAddressesCount.pointee = UInt(addresses.count) + } + return 0 +} + +/// C shim for `on_load_private_contact_scan_state_free_fn`: the rows are +/// flat, so releasing the two arrays is all there is to it. +private func loadPrivateContactScanStateFreeCallback( + context: UnsafeMutableRawPointer?, + positions: UnsafePointer?, positionsCount: UInt, + addresses: UnsafePointer?, addressesCount: UInt +) { + if let positions { + let buffer = UnsafeMutablePointer(mutating: positions) + buffer.deinitialize(count: Int(positionsCount)) + buffer.deallocate() + } + if let addresses { + let buffer = UnsafeMutablePointer(mutating: addresses) + buffer.deinitialize(count: Int(addressesCount)) + buffer.deallocate() + } +} + private func loadIdentityBalanceBlockTimeCallback( context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, identityId: UnsafePointer?, outFound: UnsafeMutablePointer?, outBlockTime: UnsafeMutablePointer? diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index b8a2f107ad7..22963ef38a3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -265,6 +265,29 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// An asset lock (a drain, or a fixed amount in many small coins) exceeds /// the input cap; nothing was broadcast. case errorAssetLockTooManyInputs = 60 + /// The contact requests with that identity came or went privately, so a + /// public request to it, in reply or as a rotation, would publish the + /// relationship. Nothing was sent; accept or send it privately. + case errorPrivateContactRequestNeedsPrivateReply = 68 + /// The operation needs the shielded pool, and the native library was + /// built without shielded support. Nothing was sent; not retryable on + /// this build. + case errorShieldedBuildRequired = 69 + /// The network is proven at a protocol version below the one that + /// serves the request (for example the private contact request store + /// before protocol version 15), so the SDK did not send it. Expected on + /// such a network; it lifts when the network upgrades. + case errorProtocolVersionNotActive = 70 + /// A private contact request to that identity is in flight, or paid for + /// and not yet read back from the store. Nothing new was paid; a later + /// private contact request sync settles it. Do not submit it again. + case errorPrivateContactRequestPending = 71 + /// The wallet, not one contact, is held: its private payments from an + /// earlier process are not restored from the shielded store yet, so no + /// public contact request is sent or accepted. Configuring the shielded + /// store or binding the wallet's shielded account restores them; a + /// private sync does not. + case errorPrivateContactPaymentsNotRestored = 72 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -396,6 +419,16 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorSpvProcessRestartRequired case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_TOO_MANY_INPUTS: self = .errorAssetLockTooManyInputs + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_REQUEST_NEEDS_PRIVATE_REPLY: + self = .errorPrivateContactRequestNeedsPrivateReply + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_BUILD_REQUIRED: + self = .errorShieldedBuildRequired + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PROTOCOL_VERSION_NOT_ACTIVE: + self = .errorProtocolVersionNotActive + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_REQUEST_PENDING: + self = .errorPrivateContactRequestPending + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_PAYMENTS_NOT_RESTORED: + self = .errorPrivateContactPaymentsNotRestored case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -587,6 +620,41 @@ public enum PlatformWalletError: LocalizedError { /// tracked lock over the cap: it is not re-sent (an earlier attempt may /// have reached the network), it stays tracked, and nothing is released. case assetLockTooManyInputs(String) + /// The contact requests with that identity came or went privately + /// (protocol version 15): a public request to it, in reply or as a + /// rotation, would publish the relationship, so the public accept and + /// send refuse it and nothing was sent. Accept it with + /// `PlatformWalletManager.acceptContactRequestReplyingInKind` (which picks + /// the reply itself) or `acceptPrivateContactRequest`, or send with + /// `PlatformWalletManager.sendPrivateContactRequest`. + case privateContactRequestNeedsPrivateReply(String) + /// The operation needs the shielded pool, and the native library was + /// built without shielded support: today, accepting a contact request + /// that came privately with + /// `PlatformWalletManager.acceptContactRequestReplyingInKind`. Nothing + /// was sent; a public or adopted reply works on such a build. + case shieldedBuildRequired(String) + /// The network does not serve the request yet: it is proven at a protocol + /// version below the one that does, so the SDK did not send it. Expected + /// on such a network (for example the private contact request store before + /// protocol version 15); a host may stay quiet about it. + case protocolVersionNotActive(String) + /// A private contact request to that identity is in flight (another + /// private send or reply reserved it), or was paid for and is not yet + /// read back from the store; or this identity's private store could not + /// be read to its end before its first private send or accept since + /// launch. Nothing new was paid; a later + /// `ManagedPlatformWallet.syncPrivateContactRequests`, or a retry, settles + /// it. Pending, not failed: do not submit it again. + case privateContactRequestPending(String) + /// The wallet's private payments from an earlier process are not + /// restored from the shielded store yet, so its public contact requests, + /// sent or accepted, are held (code 72). Nothing was signed or sent. + /// `PlatformWalletManager.configureShielded` or binding the wallet's + /// shielded account restores them; a private sync does not. + /// `ManagedPlatformWallet.privateContactReadiness(identityId:)` reads + /// whether they are restored. + case privateContactPaymentsNotRestored(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -812,6 +880,9 @@ public enum PlatformWalletError: LocalizedError { .assetLockNotTracked(let m), .assetLockAlreadyConsumed(let m), .assetLockFundingMismatch(let m), .assetLockInsufficientFunds(let m), .assetLockTooManyInputs(let m), + .privateContactRequestNeedsPrivateReply(let m), + .shieldedBuildRequired(let m), .protocolVersionNotActive(let m), + .privateContactRequestPending(let m), .privateContactPaymentsNotRestored(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -930,6 +1001,16 @@ public enum PlatformWalletError: LocalizedError { case .errorAssetLockFundingMismatch: self = .assetLockFundingMismatch(detail) case .errorAssetLockInsufficientFunds: self = .assetLockInsufficientFunds(detail) case .errorAssetLockTooManyInputs: self = .assetLockTooManyInputs(detail) + case .errorPrivateContactRequestNeedsPrivateReply: + self = .privateContactRequestNeedsPrivateReply(detail) + case .errorShieldedBuildRequired: + self = .shieldedBuildRequired(detail) + case .errorProtocolVersionNotActive: + self = .protocolVersionNotActive(detail) + case .errorPrivateContactRequestPending: + self = .privateContactRequestPending(detail) + case .errorPrivateContactPaymentsNotRestored: + self = .privateContactPaymentsNotRestored(detail) case .errorWalletAlreadyExists: self = .walletAlreadyExists(detail) case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift new file mode 100644 index 00000000000..3a40d504f8a --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -0,0 +1,443 @@ +import Foundation +import DashSDKFFI + +// MARK: - DashPay private contact requests (protocol version 15) +// +// A private contact request is sealed to one of the recipient's DashPay keys +// and paid for from the shielded pool, so nothing on chain names either side. +// It is signed by the same identity signer as a public request. The wallet +// records it as an ordinary `ContactRequest`; whether it came or went +// privately, and the contact's shielded address, are read with +// `ManagedIdentity.privateContact(contactId:)`. +// +// Accepting: `PlatformWalletManager.acceptContactRequestReplyingInKind` is +// the one accept a host calls; the wallet replies privately to a request that +// came privately, and with a document otherwise. The explicitly public +// `acceptContactRequest` and `sendContactRequest` throw +// `PlatformWalletError.privateContactRequestNeedsPrivateReply` for a contact +// whose requests came or went privately; send to it with +// `PlatformWalletManager.sendPrivateContactRequest` instead. + +/// How an identity and one contact exchanged private contact requests. Rust +/// reads it from the stored requests, so it holds across launches. The +/// contact's shielded address holds across launches too where the store +/// attests `PlatformWalletPersistenceCapabilities.privateContactRequestScanState` +/// (the SwiftData handler does); otherwise it comes back with the next +/// `ManagedPlatformWallet.syncPrivateContactRequests`. +public struct PrivateContactInfo: Sendable, Equatable { + /// The contact's request came privately, so accepting it replies + /// privately. + public let receivedPrivately: Bool + /// This identity's request to the contact went privately. + public let sentPrivately: Bool + /// The 43-byte Orchard address the contact asked to be paid at + /// privately; only when `receivedPrivately`, and only once a sync this + /// launch has read it. + public let shieldedAddress: Data? + + init(ffi: PrivateContactFFI) { + receivedPrivately = ffi.received_privately + sentPrivately = ffi.sent_privately + shieldedAddress = ffi.has_shielded_address + ? withUnsafeBytes(of: ffi.shielded_address) { Data($0) } + : nil + } +} + +/// What a contact request from one of the wallet's identities waits for, as +/// the wallet decides it (`platform_wallet_get_private_contact_readiness`), +/// so a host shows "restoring private payments" apart from "reading the +/// private store" and from a contact's pending request rather than inferring +/// them from the refusals it got. +public struct PrivateContactReadiness: Sendable, Equatable { + /// Whether the wallet's private payments from an earlier process are + /// restored for the identity. While false, its public sends and accepts, + /// the in-kind accept included, are refused with + /// `PlatformWalletError.privateContactPaymentsNotRestored` (72) until the + /// shielded store is configured or the wallet's shielded account bound; + /// no private sync lifts it. + public let privatePaymentsRestored: Bool + /// Whether a private sync read DashPay's private contact request store to + /// its end for the identity since load. Until then its first send or + /// accept reads it first, and is refused with + /// `PlatformWalletError.privateContactRequestPending` (71) while that + /// read cannot finish. + public let privateStoreRead: Bool + /// Whether a private contact request the identity paid for may still be + /// unresolved. A contact's own pending request is + /// `ManagedIdentity.privateReplyPending(contactId:)`. + public let unresolvedPrivatePayment: Bool + + public init(privatePaymentsRestored: Bool, privateStoreRead: Bool, unresolvedPrivatePayment: Bool) { + self.privatePaymentsRestored = privatePaymentsRestored + self.privateStoreRead = privateStoreRead + self.unresolvedPrivatePayment = unresolvedPrivatePayment + } + + init(ffi: PrivateContactReadinessFFI) { + self.init( + privatePaymentsRestored: ffi.private_payments_restored, + privateStoreRead: ffi.private_store_read, + unresolvedPrivatePayment: ffi.unresolved_private_payment) + } +} + +/// What one pass over DashPay's private contact request store did. +public struct PrivateContactSyncResult: Sendable { + /// The requests the pass ingested, received and sent, including ones the + /// wallet already held. + public let requests: [ContactRequest] + /// The store pages read. + public let pagesRead: UInt32 + /// Entries that opened for one of the wallet's identities but were + /// refused. + public let rejectedCount: UInt32 + /// The store position the pass stopped at. + public let nextPosition: UInt64 + /// Whether the pass read to the store's last entry. When false, sync + /// again to read on. + public let complete: Bool + /// Identities whose ingest did not reach disk; the next pass reads their + /// entries again. + public let unpersistedIdentityCount: UInt32 +} + +extension ManagedIdentity { + /// How this identity and `contactId` exchanged private contact requests, + /// or `nil` when neither tracked request between them came or went + /// privately. + public func privateContact(contactId: Identifier) throws -> PrivateContactInfo? { + var ffi = PrivateContactFFI() + let result = contactId.withFFIBytes { idPtr in + PlatformWalletResult(managed_identity_get_private_contact(handle, idPtr, &ffi)) + } + if result.code == .notFound { + return nil + } + try result.throwIfError() + return PrivateContactInfo(ffi: ffi) + } + + /// Whether a private contact request from this identity to `contactId` + /// is being paid for, or was paid for and is not yet read back from the + /// store. While it is, Rust refuses another private send or reply to the + /// contact (`PlatformWalletError.privateContactRequestPending`), so show + /// the contact as pending and offer neither; a private sync settles it. + /// Read from this snapshot: take a fresh `ManagedIdentity` to see a + /// change. After a relaunch, a reply paid for in an earlier process is + /// pending again once Rust restores it from the shielded store, which it + /// does when the store is configured after the load + /// (`PlatformWalletManager.configureShielded`) or the wallet binds its + /// shielded account. + public func privateReplyPending(contactId: Identifier) throws -> Bool { + var pending = false + let result = contactId.withFFIBytes { idPtr in + PlatformWalletResult(managed_identity_get_private_reply_pending(handle, idPtr, &pending)) + } + try result.throwIfError() + return pending + } +} + +extension ManagedPlatformWallet { + /// What a contact request from `identityId`, one of this wallet's + /// identities, waits for, as the wallet decides it. A pure read of live + /// wallet state; read it again after the shielded store is configured or + /// the wallet binds its shielded account to see the restore. + public func privateContactReadiness(identityId: Identifier) throws -> PrivateContactReadiness { + var ffi = PrivateContactReadinessFFI() + let handle = self.handle + let result = identityId.withFFIBytes { idPtr in + platform_wallet_get_private_contact_readiness(handle, idPtr, &ffi) + } + try result.check() + return PrivateContactReadiness(ffi: ffi) + } + + /// Read DashPay's private contact request store for every identity of + /// this wallet, at most `maxPages` pages (the default reads to the end), + /// and ingest what it holds for them: requests to them, each sender + /// checked, and requests they sent, recovered from the store alone. + /// + /// The store is opened with keys that stay in the Keychain mnemonic + /// resolver Rust-side; each request's signature is checked against its + /// sender's keys. Where the store attests + /// `PlatformWalletPersistenceCapabilities.privateContactRequestScanState` + /// (the SwiftData handler does), each identity's read position is stored + /// with the requests read before it and restored at load, so a pass + /// after launch reads on from there; otherwise the first pass after + /// launch reads the store from its start. A request it ingests is stored + /// as having come privately, which holds across launches. + public func syncPrivateContactRequests( + maxPages: UInt32 = .max + ) async throws -> PrivateContactSyncResult { + let handle = self.handle + let coreSigner = MnemonicResolver() + return try await Task.detached(priority: .userInitiated) { + () -> PrivateContactSyncResult in + var array = ContactRequestHandleArray() + var report = PrivateContactSyncReportFFI() + // Keep the resolver alive across the synchronous FFI call: its + // vtable callback fires during it. + let result = withExtendedLifetime(coreSigner) { + platform_wallet_sync_private_contact_requests( + handle, + coreSigner.handle, + maxPages, + &array, + &report + ) + } + defer { platform_wallet_contact_request_handle_array_free(&array) } + try result.check() + var requests: [ContactRequest] = [] + if let handles = array.handles, array.count > 0 { + requests.reserveCapacity(Int(array.count)) + for i in 0.. ContactRequest { + let handle = try privateContactRequestManagerHandle( + walletId: walletId, + resolver: resolver, + shieldedAddress: shieldedAddress + ) + // Counted as the other native manager operations are, so a teardown + // waits for this call to return before it frees the manager. + try admitNativeOp("sendPrivateContactRequest") + defer { finishNativeOp() } + let signerHandle = signer.handle + let requestHandle: Handle = try await Task.detached(priority: .userInitiated) { + () -> Handle in + var outHandle: Handle = NULL_HANDLE + let resolverHandle = resolver.handle + // Guaranteed keepalive of the resolver and the signer across the + // FFI call, whose vtable callbacks fire during it: same rationale + // as `shieldedTransfer`. + try withExtendedLifetime((resolver, signer)) { + try walletId.withFFIBytes { widPtr in + try senderIdentityId.withFFIBytes { senderPtr in + try recipientIdentityId.withFFIBytes { recipientPtr in + try withOptionalCString(accountLabel) { labelPtr in + try withOptionalBytes(autoAcceptProof) { proofPtr, proofLen in + try withOptionalBytes(shieldedAddress) { addressPtr, _ in + try platform_wallet_manager_send_private_contact_request( + handle, + widPtr, + resolverHandle, + account, + senderPtr, + recipientPtr, + labelPtr, + proofPtr, + proofLen, + addressPtr, + signerHandle, + &outHandle + ).check() + } + } + } + } + } + } + } + return outHandle + }.value + return ContactRequest(handle: requestHandle) + } + + /// Accept a DashPay contact request that came privately, replying + /// privately and paying for the reply from `account`'s shielded notes. + /// `shieldedAddress` is the 43-byte Orchard address the contact may pay + /// us at privately. `signer` is the identity signer + /// `acceptContactRequest` takes; it signs the reply. An unconfirmed or + /// pending reply throws as `sendPrivateContactRequest` does. + public func acceptPrivateContactRequest( + walletId: Data, + resolver: MnemonicResolver, + account: UInt32 = 0, + request: ContactRequest, + shieldedAddress: Data? = nil, + signer: KeychainSigner + ) async throws -> EstablishedContact { + let handle = try privateContactRequestManagerHandle( + walletId: walletId, + resolver: resolver, + shieldedAddress: shieldedAddress + ) + // Counted as the other native manager operations are, so a teardown + // waits for this call to return before it frees the manager. + try admitNativeOp("acceptPrivateContactRequest") + defer { finishNativeOp() } + let requestHandle = request.handle + let signerHandle = signer.handle + let establishedHandle: Handle = try await Task.detached(priority: .userInitiated) { + () -> Handle in + var outHandle: Handle = NULL_HANDLE + let resolverHandle = resolver.handle + try withExtendedLifetime((resolver, signer, request)) { + try walletId.withFFIBytes { widPtr in + try withOptionalBytes(shieldedAddress) { addressPtr, _ in + try platform_wallet_manager_accept_private_contact_request( + handle, + widPtr, + resolverHandle, + account, + requestHandle, + addressPtr, + signerHandle, + &outHandle + ).check() + } + } + } + return outHandle + }.value + return EstablishedContact(handle: establishedHandle) + } + + /// Accept a DashPay contact request replying the way it came: privately, + /// paid from `account`'s shielded notes, when it came privately and no + /// reply exists yet; otherwise with a `contactRequest` document signed by + /// `signer`, or by adopting the reply already sent. Which reply a request + /// takes is the wallet's decision, so this is the accept a host calls. + /// `shieldedAddress` is the 43-byte Orchard address the contact may pay + /// us at privately; a public reply ignores it. A public or adopted reply + /// needs neither shielded support nor a mnemonic read for the payment. + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when a private + /// reply may already be on chain, or `.privateContactRequestPending` when + /// one is in flight or paid and not yet read back: do not accept again; + /// and + /// `PlatformWalletError.shieldedBuildRequired` when the request needs a + /// private reply and the native library has no shielded support. + public func acceptContactRequestReplyingInKind( + walletId: Data, + resolver: MnemonicResolver, + account: UInt32 = 0, + request: ContactRequest, + shieldedAddress: Data? = nil, + signer: KeychainSigner + ) async throws -> EstablishedContact { + let handle = try privateContactRequestManagerHandle( + walletId: walletId, + resolver: resolver, + shieldedAddress: shieldedAddress + ) + // Counted as the other native manager operations are, so a teardown + // waits for this call to return before it frees the manager. + try admitNativeOp("acceptContactRequestReplyingInKind") + defer { finishNativeOp() } + let requestHandle = request.handle + let signerHandle = signer.handle + let establishedHandle: Handle = try await Task.detached(priority: .userInitiated) { + () -> Handle in + var outHandle: Handle = NULL_HANDLE + let resolverHandle = resolver.handle + // Keep the resolver, the signer and the request alive across the + // FFI call, whose vtable callbacks fire during it. + try withExtendedLifetime((resolver, signer, request)) { + try walletId.withFFIBytes { widPtr in + try withOptionalBytes(shieldedAddress) { addressPtr, _ in + try platform_wallet_manager_accept_contact_request_replying_in_kind( + handle, + widPtr, + resolverHandle, + account, + requestHandle, + addressPtr, + signerHandle, + &outHandle + ).check() + } + } + } + return outHandle + }.value + return EstablishedContact(handle: establishedHandle) + } + + /// The manager handle of a private contact request call, refused before + /// any FFI call when the arguments cannot be passed: an unconfigured + /// manager, a wallet id that is not 32 bytes, a shielded address that is + /// not the 43 bytes Rust reads, or a resolver without a handle. + private func privateContactRequestManagerHandle( + walletId: Data, + resolver: MnemonicResolver, + shieldedAddress: Data? + ) throws -> Handle { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle("PlatformWalletManager not configured") + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter("walletId must be exactly 32 bytes") + } + if let shieldedAddress, shieldedAddress.count != 43 { + throw PlatformWalletError.invalidParameter( + "shieldedAddress must be exactly 43 raw Orchard bytes" + ) + } + guard resolver.handle != nil else { + throw PlatformWalletError.invalidParameter("MnemonicResolver has no handle") + } + return handle + } +} + +/// Run `body` with `text` as a C string, or with null when `text` is nil. +private func withOptionalCString( + _ text: String?, + _ body: (UnsafePointer?) throws -> R +) rethrows -> R { + guard let text else { return try body(nil) } + return try text.withCString { try body($0) } +} + +/// Run `body` with a pointer to `data` and its length, or with `(nil, 0)` +/// when `data` is nil or empty. +private func withOptionalBytes( + _ data: Data?, + _ body: (UnsafePointer?, UInt) throws -> R +) rethrows -> R { + guard let data, !data.isEmpty else { return try body(nil, 0) } + return try data.withUnsafeBytes { raw in + try body(raw.bindMemory(to: UInt8.self).baseAddress, UInt(data.count)) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 057fca7a0dd..d76f2fe1745 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -1153,7 +1153,7 @@ class ShieldedService: ObservableObject { /// store via `SubwalletId`, so multiple wallets cohabiting /// the same SQLite file no longer leak notes across each /// other. (See `wallet/shielded/store.rs` for the trait.) - private static func dbPath(for network: Network) -> String { + static func dbPath(for network: Network) -> String { let docs = FileManager.default .urls(for: .documentDirectory, in: .userDomainMask) .first! diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 7a0f020ff9b..be26d80415c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -18,6 +18,9 @@ struct AddContactView: View { let onSent: (Identifier, String?) -> Void @EnvironmentObject var walletManager: PlatformWalletManager + /// Observed for its bind: binding the shielded account restores the + /// wallet's private payments, so the readiness is read again then. + @EnvironmentObject var shieldedService: ShieldedService @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -50,9 +53,28 @@ struct AddContactView: View { /// as the contact's "Their account" hint. @State private var accountLabel = "" + /// Send a private contact request (protocol version 15): paid from the + /// wallet's shielded balance, so nothing on chain names either side. + @State private var sendPrivately = false + @State private var isSending = false @State private var errorMessage: String? + /// Set when a private send or reply may have gone through (its result + /// could not be confirmed): the view only offers to close, never to + /// submit again, as `SendTransactionView` does for an unconfirmed spend. + @State private var unconfirmedNotice: String? + + /// Whether the wallet has a private request to the resolved recipient in + /// flight, or paid and not yet read back (`privateRepliesPending`). While + /// it does, Send is off and the footer offers a private sync, which + /// settles it. Read from the wallet, so reopening this sheet keeps it. A + /// failed read keeps Send off until a read succeeds. + @State private var recipientPending = PrivateRepliesPendingSnapshot() + + /// A private sync the footer started is running. + @State private var isSyncingPrivate = false + /// Send-collision flow. @State private var showCollisionAlert = false @State private var collisionRecipient: Identifier? @@ -90,6 +112,17 @@ struct AddContactView: View { resolvedRecipient != nil && resolvedRecipient != identity.identityId && !isSending + && unconfirmedNotice == nil + && resolvedRecipient.map(recipientPending.allowsSubmission(to:)) == true + } + + /// Whether the footer shows the recipient's pending state: the wallet's + /// private payments are being restored, a private request to them is + /// pending, or the wallet could not be read. + private var recipientBlocked: Bool { + guard let recipient = resolvedRecipient else { return false } + return recipientPending.isRestoringPrivatePayments + || recipientPending.isPending(recipient) || recipientPending.isUnavailable } var body: some View { @@ -114,6 +147,7 @@ struct AddContactView: View { if let recipient = resolvedRecipient { previewSection(recipient: recipient) accountLabelSection + sendPrivatelySection sendSection } @@ -127,6 +161,8 @@ struct AddContactView: View { } } .navigationTitle("Add Contact") + .task(id: resolvedRecipient) { reloadRecipientPending() } + .onChange(of: shieldedService.isBound) { _, _ in reloadRecipientPending() } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -140,14 +176,24 @@ struct AddContactView: View { isPresented: $showCollisionAlert, presenting: collisionRecipient ) { recipient in - Button("Accept") { + Button(CollisionAccept.of(sendPrivately: sendPrivately).buttonTitle) { acceptIncoming(from: recipient) } Button("Continue anyway") { send(to: recipient) } } message: { _ in - Text("This person already sent you a request — accept it instead?") + Text(CollisionAccept.of(sendPrivately: sendPrivately).message) + } + .alert( + "Submitted", + isPresented: .constant(unconfirmedNotice != nil) + ) { + Button("Done") { dismiss() } + } message: { + if let unconfirmedNotice { + Text(unconfirmedNotice) + } } } } @@ -357,6 +403,15 @@ struct AddContactView: View { } } + private var sendPrivatelySection: some View { + Section { + Toggle("Send privately", isOn: $sendPrivately) + .accessibilityIdentifier("dashpay.addContact.sendPrivately") + } footer: { + Text("Paid from this wallet's shielded balance, so nothing on chain names either of you.") + } + } + private var sendSection: some View { Section { Button { @@ -374,6 +429,66 @@ struct AddContactView: View { } .disabled(!canSend) .accessibilityIdentifier("dashpay.addContact.send") + } footer: { + if let recipient = resolvedRecipient, recipientBlocked { + VStack(alignment: .leading, spacing: 6) { + Text(ContactRequestSubmissionOutcome.rowMessage( + pending: recipientPending.isPending(recipient), + restoring: recipientPending.isRestoringPrivatePayments, + unavailable: recipientPending.isUnavailable, + error: nil + ) ?? "") + .accessibilityIdentifier("dashpay.addContact.pending") + // A private sync settles a pending request; it does not + // restore private payments, so it is not offered for that. + if !recipientPending.isRestoringPrivatePayments { + Button { + syncPrivateRequests() + } label: { + if isSyncingPrivate { + ProgressView() + } else { + Text("Sync private requests") + } + } + .disabled(isSyncingPrivate) + .accessibilityIdentifier("dashpay.addContact.syncPrivate") + } + } + } + } + } + + /// Re-read from the wallet whether a private request to the resolved + /// recipient is pending. + private func reloadRecipientPending() { + let contactIds = resolvedRecipient.map { [$0] } ?? [] + let wallet = try? requireWallet() + recipientPending.record( + privateRepliesPending( + wallet: wallet, + identityId: identity.identityId, + contactIds: contactIds + ), + for: contactIds + ) + recipientPending.recordReadiness( + privateContactReadiness(wallet: wallet, identityId: identity.identityId)) + } + + /// Read the private store, which settles a pending private request, then + /// re-read the recipient's pending state: Send comes back once nothing + /// is pending, for the user to try again. + private func syncPrivateRequests() { + isSyncingPrivate = true + Task { @MainActor in + defer { isSyncingPrivate = false } + if let wallet = try? requireWallet() { + if let notice = await syncPrivateContactRequests(wallet) { + errorMessage = notice + } + } + reloadRecipientPending() } } @@ -451,19 +566,49 @@ struct AddContactView: View { defer { isSending = false } do { let wallet = try requireWallet() - let signer = KeychainSigner(modelContainer: modelContext.container) let label = accountLabel.trimmingCharacters(in: .whitespacesAndNewlines) - _ = try await wallet.sendContactRequest( - senderIdentityId: identity.identityId, - recipientIdentityId: recipient, - accountLabel: label.isEmpty ? nil : label, - signer: signer - ) + // Both sends sign with the same identity signer. + let signer = KeychainSigner(modelContainer: modelContext.container) + if sendPrivately { + _ = try await walletManager.sendPrivateContactRequest( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + senderIdentityId: identity.identityId, + recipientIdentityId: recipient, + accountLabel: label.isEmpty ? nil : label, + signer: signer + ) + } else { + _ = try await wallet.sendContactRequest( + senderIdentityId: identity.identityId, + recipientIdentityId: recipient, + accountLabel: label.isEmpty ? nil : label, + signer: signer + ) + } onSent(recipient, mode == .dpns ? selectedResult?.fullName : nil) kickDashPaySync(walletManager) dismiss() } catch { - errorMessage = error.localizedDescription + switch ContactRequestSubmissionOutcome.of(error) { + case .mayHaveGoneThrough: + // Pending, not failed: the next sync settles it. + onSent(recipient, mode == .dpns ? selectedResult?.fullName : nil) + kickDashPaySync(walletManager) + unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + case .privateRequestPending: + // Nothing was sent, and the wallet may hold nothing for + // it: the footer shows it as pending and offers the + // private sync that settles it. + recipientPending.recordRefusal(of: recipient) + case .privatePaymentsNotRestored: + // Nothing was sent: the footer shows the wallet restoring + // its private payments until its readiness says restored. + recipientPending.recordRestoringRefusal() + case .failed(let message): + errorMessage = message + } + reloadRecipientPending() } } } @@ -472,6 +617,11 @@ struct AddContactView: View { /// `ContactRequest` and accept it — establishing the contact /// directly instead of sending a redundant request. private func acceptIncoming(from recipient: Identifier) { + // A private request to this contact may be pending in the wallet. + guard recipientPending.allowsSubmission(to: recipient) else { return } + // "Send privately" is the user's choice of a private request, so it + // holds for the accept too (`CollisionAccept`). + let accept = CollisionAccept.of(sendPrivately: sendPrivately) isSending = true errorMessage = nil Task { @MainActor in @@ -486,11 +636,42 @@ struct AddContactView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - _ = try await wallet.acceptContactRequest(request, signer: signer) + switch accept { + case .privateReply: + // A private reply, paid from the shielded balance, even to + // a request that came publicly. + _ = try await walletManager.acceptPrivateContactRequest( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + request: request, + signer: signer + ) + case .replyInKind: + // The wallet replies the way the request came: privately + // (paid from the shielded balance) or with a document. + _ = try await walletManager.acceptContactRequestReplyingInKind( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + request: request, + signer: signer + ) + } kickDashPaySync(walletManager) dismiss() } catch { - errorMessage = "Accept failed: \(error.localizedDescription)" + switch ContactRequestSubmissionOutcome.of(error) { + case .mayHaveGoneThrough: + kickDashPaySync(walletManager) + unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + case .privateRequestPending: + // Nothing was sent: the footer offers the private sync. + recipientPending.recordRefusal(of: recipient) + case .privatePaymentsNotRestored: + recipientPending.recordRestoringRefusal() + case .failed(let message): + errorMessage = "Accept failed: \(message)" + } + reloadRecipientPending() } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift new file mode 100644 index 00000000000..2289a163dbd --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -0,0 +1,239 @@ +import Foundation +import SwiftDashSDK + +/// How a contact request send or accept that threw ended, for the DashPay +/// views. Follows `SendViewModel`: a shielded spend whose result could not be +/// confirmed may already be on chain (Rust keeps its notes reserved), so the +/// view keeps it pending and never offers a fresh submission; the next sync +/// settles it. A private request Rust reports as pending (one to the same +/// contact in flight, or paid and not yet read back), which a public send or +/// accept can also meet, sent nothing new: the view shows it as pending and +/// offers a private sync, which settles it, after which the user may try +/// again. Unrestored private payments (code 72) hold the wallet, not one +/// contact: the view shows "restoring private payments" until the wallet's +/// readiness reads restored; no private sync lifts it. Every other error is +/// an ordinary failure the user may retry. +enum ContactRequestSubmissionOutcome: Equatable { + /// The private send or reply may have gone through: do not submit again. + case mayHaveGoneThrough + /// A private request to the contact is pending in the wallet; nothing + /// was sent. A private sync settles it. + case privateRequestPending + /// The wallet's private payments from an earlier process are not + /// restored yet; nothing was sent. Restoring them, not a private sync, + /// lifts it. + case privatePaymentsNotRestored + /// It failed; the message is the error's. + case failed(String) + + static let mayHaveGoneThroughMessage = "This may have gone through. It shows up " + + "after the next sync confirms it; do not submit it again." + + static let privateRequestPendingMessage = "A private request to this contact is " + + "pending. Sync private requests to settle it, then try again if it did not " + + "go through." + + static let restoringPrivatePaymentsMessage = "Restoring this wallet's private " + + "payments. Contact requests wait until they are restored." + + /// The message a request row shows: the restoring notice while the + /// wallet's private payments are not restored, the pending notice while + /// the wallet has a private request to the contact pending, the + /// unavailable notice while the wallet could not be read, and whatever + /// error an earlier attempt left otherwise. + static func rowMessage( + pending: Bool, restoring: Bool = false, unavailable: Bool = false, error: String? + ) -> String? { + if restoring { return restoringPrivatePaymentsMessage } + if pending { return privateRequestPendingMessage } + if unavailable { return PrivateRepliesPendingSnapshot.unavailableMessage } + return error + } + + static func of(_ error: Error) -> ContactRequestSubmissionOutcome { + if let walletError = error as? PlatformWalletError { + switch walletError { + case .shieldedSpendUnconfirmed: + return .mayHaveGoneThrough + case .privateContactRequestPending: + return .privateRequestPending + case .privateContactPaymentsNotRestored: + return .privatePaymentsNotRestored + default: + break + } + } + return .failed(error.localizedDescription) + } +} + +/// Which contacts have a private request pending in the wallet, as the views +/// last read it (`privateRepliesPending`). While one is, Rust refuses another +/// send or reply to that contact, so the views turn off what would submit one +/// (Accept, Ignore, Send). A failed read is not "nothing pending": the last +/// successful snapshot stays, and those controls stay off for every contact +/// until a read covering it succeeds. +/// +/// A send or accept Rust refused as pending (code 71) is kept apart from the +/// read: code 71 also means the private store could not be read to its end +/// before the first send or accept since load, with nothing reserved, so the +/// read may find nothing pending. That contact stays pending, with the +/// private sync offered, until the wallet's readiness says the store was +/// read to its end. +/// +/// Whether the wallet's private payments are restored is the wallet's +/// readiness (`ManagedPlatformWallet.privateContactReadiness`), not +/// anything the views conclude: a code 72 refusal, or a readiness read that +/// says not restored, shows "restoring private payments" for every contact, +/// and only a readiness read that says restored lifts it. A private sync +/// changes neither on its own. +struct PrivateRepliesPendingSnapshot: Equatable { + static let unavailableMessage = "Could not read whether a private request to " + + "this contact is pending. Sync private requests to check again." + + /// The pending contacts the last successful read found. + private(set) var pending: Set = [] + /// The contacts the last successful read covered. + private(set) var covered: Set = [] + /// False after a failed read, until a read succeeds. + private(set) var available = true + /// Contacts a send or accept was refused for as pending (code 71), until + /// the wallet's readiness says the private store was read to its end. + private(set) var refused: Set = [] + /// The wallet's readiness for the identity, as last read; nil until a + /// read succeeds. + private(set) var readiness: PrivateContactReadiness? + /// Set by a code 72 refusal; cleared only by a readiness read that says + /// the private payments are restored. + private(set) var restoringRefused = false + + /// Record a read of `contactIds`: the pending ones, or nil when the read + /// failed. + mutating func record(_ read: Set?, for contactIds: [Data]) { + guard let read else { + available = false + return + } + pending = read + covered = Set(contactIds) + available = true + } + + /// Record that a send or accept to `contactId` was refused as pending + /// (code 71). + mutating func recordRefusal(of contactId: Data) { + refused.insert(contactId) + } + + /// Record a send or accept refused because the wallet's private payments + /// are not restored (code 72). + mutating func recordRestoringRefusal() { + restoringRefused = true + } + + /// Record a read of the wallet's readiness, or nil when it failed, which + /// keeps what the last read said. A readiness that says restored lifts a + /// code 72 refusal; one that says the private store was read to its end + /// leaves a contact's pending state to the pending read. + mutating func recordReadiness(_ read: PrivateContactReadiness?) { + guard let read else { return } + readiness = read + if read.privatePaymentsRestored { + restoringRefused = false + } + if read.privateStoreRead { + refused.removeAll() + } + } + + /// Whether the wallet's private payments are being restored: a send or + /// accept was refused with code 72, or the last readiness read says they + /// are not restored. Every contact waits while they are. + var isRestoringPrivatePayments: Bool { + restoringRefused || readiness?.privatePaymentsRestored == false + } + + /// Whether `contactId` is pending: the last successful read found a + /// private request to it, or a send or accept to it was refused as + /// pending and the store has not been read to its end since. + func isPending(_ contactId: Data) -> Bool { + pending.contains(contactId) || refused.contains(contactId) + } + + /// Whether the last read failed. + var isUnavailable: Bool { !available } + + /// Whether a send or reply to `contactId` may be offered: a successful + /// read covered it and found nothing pending. + func allowsSubmission(to contactId: Data) -> Bool { + available && covered.contains(contactId) && !isPending(contactId) + && !isRestoringPrivatePayments + } +} + +/// How Add Contact's collision alert accepts the request the recipient +/// already sent us. With "Send privately" on, the user chose a private +/// request, so the accept replies privately +/// (`PlatformWalletManager.acceptPrivateContactRequest`, paid from the +/// shielded balance as the private send is) even when their request came +/// publicly: a public reply would publish this user as its owner and the +/// contact as its recipient, against that choice. With it off, the wallet +/// replies the way the request came +/// (`PlatformWalletManager.acceptContactRequestReplyingInKind`). The toggle is +/// the user's explicit choice; which reply an in-kind accept takes stays the +/// wallet's. +enum CollisionAccept: Equatable { + /// Reply privately, paid from the shielded balance. + case privateReply + /// Reply the way the request came. + case replyInKind + + static func of(sendPrivately: Bool) -> CollisionAccept { + sendPrivately ? .privateReply : .replyInKind + } + + /// The alert's accept button. + var buttonTitle: String { + switch self { + case .privateReply: return "Accept privately" + case .replyInKind: return "Accept" + } + } + + /// The alert's message. + var message: String { + switch self { + case .privateReply: + return "This person already sent you a request. Accept it with a private reply " + + "instead? The reply is paid from this wallet's shielded balance, and no " + + "public request names you as its sender." + case .replyInKind: + return "This person already sent you a request. Accept it instead?" + } + } +} + +/// What the request list's private-store refresh tells the user. A network +/// that does not serve the store yet (before protocol version 15) is expected +/// and stays quiet; an operational failure, or requests read but not saved, +/// is shown so the user knows to refresh again. +enum PrivateContactRefreshNotice { + /// The notice a pass that read the store leaves, or nil when every + /// identity's requests reached disk. + static func notice(unpersistedIdentityCount: UInt32) -> String? { + guard unpersistedIdentityCount > 0 else { return nil } + return "Private requests for \(unpersistedIdentityCount) " + + (unpersistedIdentityCount == 1 ? "identity were" : "identities were") + + " read but not saved. Pull to refresh again." + } + + /// The notice a pass that failed leaves, or nil when the network does not + /// serve the private store yet. + static func notice(for error: Error) -> String? { + if let walletError = error as? PlatformWalletError, + case .protocolVersionNotActive = walletError { + return nil + } + return "Private requests could not be read: \(error.localizedDescription)" + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 0ad941084cb..070dc4a1fcf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -20,6 +20,9 @@ struct ContactRequestsView: View { @Binding var optimisticSentIds: Set @EnvironmentObject var walletManager: PlatformWalletManager + /// Observed for its bind: binding the shielded account restores the + /// wallet's private payments, so the readiness is read again then. + @EnvironmentObject var shieldedService: ShieldedService @EnvironmentObject var contactMeta: DashPayContactMetaStore @Environment(\.modelContext) private var modelContext @@ -41,6 +44,18 @@ struct ContactRequestsView: View { /// with an inline error on the row). @State private var rowErrors: [Data: String] = [:] + /// Contact ids with a private reply in flight, or paid and not yet read + /// back, as the wallet records it (`privateRepliesPending`): the row stays + /// pending, without its buttons, until a private sync settles it. Read + /// from the wallet on appear, after an accept and after each sync pass, + /// so leaving and reopening the screen keeps it. A failed read keeps the + /// last snapshot and leaves every row without its buttons until a read + /// succeeds. + @State private var walletPending = PrivateRepliesPendingSnapshot() + + /// What the last private-store refresh has to tell the user, if anything. + @State private var privateRefreshNotice: String? + init(identity: PersistentIdentity, optimisticSentIds: Binding>) { self.identity = identity _optimisticSentIds = optimisticSentIds @@ -105,6 +120,7 @@ struct ContactRequestsView: View { if incomingPending.isEmpty && outgoingPending.isEmpty && optimisticOutgoing.isEmpty { List { + privateRefreshNoticeSection DashPayListEmptyRow( icon: "tray", title: "No pending requests", @@ -114,6 +130,7 @@ struct ContactRequestsView: View { .listStyle(.insetGrouped) } else { List { + privateRefreshNoticeSection if !incomingPending.isEmpty { Section { ForEach(incomingPending, id: \.contactIdentityId) { row in @@ -126,8 +143,14 @@ struct ContactRequestsView: View { // contact is accepted (established rows load it normally). avatarUrl: nil, createdAtMillis: row.createdAtMillis, - isInFlight: inFlightIds.contains(row.contactIdentityId), - errorMessage: rowErrors[row.contactIdentityId], + isInFlight: inFlightIds.contains(row.contactIdentityId) + || !walletPending.allowsSubmission(to: row.contactIdentityId), + errorMessage: ContactRequestSubmissionOutcome.rowMessage( + pending: walletPending.isPending(row.contactIdentityId), + restoring: walletPending.isRestoringPrivatePayments, + unavailable: walletPending.isUnavailable, + error: rowErrors[row.contactIdentityId] + ), onAccept: { accept(contactId: row.contactIdentityId) }, onIgnore: { ignore(contactId: row.contactIdentityId) } ) @@ -165,9 +188,16 @@ struct ContactRequestsView: View { } .refreshable { await attachOrStartSync(walletManager) + if let wallet = try? requireWallet() { + privateRefreshNotice = await syncPrivateContactRequests(wallet) + } + reloadPendingReplies() } + .task { reloadPendingReplies() } + .onChange(of: shieldedService.isBound) { _, _ in reloadPendingReplies() } .onChange(of: rowSignature) { _, _ in pruneOverlays() + reloadPendingReplies() } .onChange(of: walletManager.dashPaySyncIsSyncing) { _, syncing in // Fallback clearing rule: after the next completed @@ -177,10 +207,41 @@ struct ContactRequestsView: View { if !syncing { removedOverlayIds.removeAll() optimisticSentIds.removeAll() + reloadPendingReplies() + } + } + } + + /// The last private-store refresh's notice, when it has one. + @ViewBuilder + private var privateRefreshNoticeSection: some View { + if let privateRefreshNotice { + Section { + Text(privateRefreshNotice) + .font(.caption) + .foregroundColor(.orange) + .accessibilityIdentifier("dashpay.requests.privateRefreshNotice") } } } + /// Re-read which incoming rows have a private reply pending, from the + /// wallet. + private func reloadPendingReplies() { + let contactIds = incomingPending.map(\.contactIdentityId) + let wallet = try? requireWallet() + walletPending.record( + privateRepliesPending( + wallet: wallet, + identityId: identity.identityId, + contactIds: contactIds + ), + for: contactIds + ) + walletPending.recordReadiness( + privateContactReadiness(wallet: wallet, identityId: identity.identityId)) + } + // MARK: - Overlay maintenance /// Drop overlay entries the `@Query` already reflects: @@ -225,14 +286,41 @@ struct ContactRequestsView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - _ = try await wallet.acceptContactRequest(request, signer: signer) + // The wallet replies the way the request came: privately + // (paid from the shielded balance) or with a document. + _ = try await walletManager.acceptContactRequestReplyingInKind( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + request: request, + signer: signer + ) // Optimistic removal — the persister will promote the // pair to established shortly. removedOverlayIds.insert(contactId) kickDashPaySync(walletManager) } catch { - rowErrors[contactId] = "Accept failed: \(error.localizedDescription)" + switch ContactRequestSubmissionOutcome.of(error) { + case .mayHaveGoneThrough: + // As `SendViewModel` treats an unconfirmed spend: not a + // failure to retry. The wallet keeps the reply pending, + // and the row reads that; the next sync settles it. + kickDashPaySync(walletManager) + case .privateRequestPending: + // Nothing was sent, and the wallet may hold nothing for + // it (the store was not read to its end): the row stays + // pending until the wallet's readiness says the store was + // read (pull to refresh reads it). + walletPending.recordRefusal(of: contactId) + kickDashPaySync(walletManager) + case .privatePaymentsNotRestored: + // The wallet is held until its private payments are + // restored; every row waits for the readiness to say so. + walletPending.recordRestoringRefusal() + case .failed(let message): + rowErrors[contactId] = "Accept failed: \(message)" + } } + reloadPendingReplies() } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index edc73820e76..2a241112dcc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -226,6 +226,70 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { Task { _ = try? await walletManager.dashPaySyncNow() } } +/// The contacts among `contactIds` that `identityId` has a private request +/// to in flight, or paid and not yet read back, as the wallet records it +/// (`ManagedIdentity.privateReplyPending`, one snapshot). While one is, Rust +/// refuses another send or reply to it, so the views show it as pending and +/// offer neither; a private sync settles it. Empty when the wallet does not +/// manage the identity, as it then holds nothing for it. Nil when the wallet +/// could not be read (no loaded wallet, or a read failed), which the views +/// keep apart from "nothing pending" (`PrivateRepliesPendingSnapshot`). +@MainActor +func privateRepliesPending( + wallet: ManagedPlatformWallet?, + identityId: Data, + contactIds: [Data] +) -> Set? { + guard !contactIds.isEmpty else { return [] } + guard let wallet else { return nil } + do { + let managed: ManagedIdentity + do { + managed = try wallet.managedIdentity(identityId: identityId) + } catch PlatformWalletError.notFound(_) { + return [] + } + var pending = Set() + for contactId in contactIds { + if try managed.privateReplyPending(contactId: contactId) { + pending.insert(contactId) + } + } + return pending + } catch { + return nil + } +} + +/// The wallet's readiness for `identityId`'s contact requests, or nil when +/// it could not be read (`PrivateRepliesPendingSnapshot.recordReadiness` +/// keeps the last one then). +@MainActor +func privateContactReadiness( + wallet: ManagedPlatformWallet?, + identityId: Data +) -> PrivateContactReadiness? { + try? wallet?.privateContactReadiness(identityId: identityId) +} + +/// Read DashPay's private contact request store (protocol version 15) for +/// `wallet`, returning what the refresh should tell the user, or nil. +/// Private requests reach the wallet only through this read, so it runs with +/// every pull-to-refresh of the request list. A network before protocol +/// version 15 does not serve the store yet, which is expected and quiet; any +/// other failure, or requests read but not saved, is reported. +@MainActor +func syncPrivateContactRequests(_ wallet: ManagedPlatformWallet) async -> String? { + do { + let result = try await wallet.syncPrivateContactRequests() + return PrivateContactRefreshNotice.notice( + unpersistedIdentityCount: result.unpersistedIdentityCount + ) + } catch { + return PrivateContactRefreshNotice.notice(for: error) + } +} + // MARK: - Row model + view /// UI model for one established contact row, resolved from the diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index a567917dedf..d5565d27f42 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -76,6 +76,20 @@ struct StorageExplorerView: View { ) { DashpayIgnoredSenderStorageListView(network: network) } + modelRow( + "Private Request Positions", + icon: "bookmark", + type: PersistentPrivateContactRequestsPosition.self + ) { + PrivateContactRequestsPositionStorageListView(network: network) + } + modelRow( + "Private Shielded Addresses", + icon: "lock.rectangle", + type: PersistentPrivateShieldedAddress.self + ) { + PrivateShieldedAddressStorageListView(network: network) + } modelRow( "Sent Invitations", icon: "paperplane", @@ -291,6 +305,8 @@ struct StorageExplorerView: View { directCount(PersistentDashpayContactProfile.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayPayment.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayIgnoredSender.self, predicate: #Predicate { $0.networkRaw == raw }) + directCount(PersistentPrivateContactRequestsPosition.self, predicate: #Predicate { $0.networkRaw == raw }) + directCount(PersistentPrivateShieldedAddress.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDocument.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDataContract.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentTokenBalance.self, predicate: #Predicate { $0.networkRaw == raw }) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 23bc60dd8c3..fb8ff7e41bb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -63,6 +63,68 @@ struct IdentityBalanceMetadataStorageListView: View { } } +// MARK: - PersistentPrivateContactRequestsPosition + +struct PrivateContactRequestsPositionStorageListView: View { + let network: Network + @Query private var records: [PersistentPrivateContactRequestsPosition] + + private var filtered: [PersistentPrivateContactRequestsPosition] { + records.filter { $0.networkRaw == network.rawValue } + .sorted { $0.identityId.toHexString() < $1.identityId.toHexString() } + } + + var body: some View { + let visible = filtered + List(visible) { record in + NavigationLink(destination: PrivateContactRequestsPositionStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text(record.identityId.toHexString()) + .font(.body).lineLimit(1).truncationMode(.middle) + Text("Wallet: \(record.walletId.toHexString())") + .font(.caption).foregroundColor(.secondary) + .lineLimit(1).truncationMode(.middle) + // Stored as a bit pattern; show the unsigned position. + Text("Position \(UInt64(bitPattern: record.position))") + .font(.caption).foregroundColor(.secondary) + } + } + } + .navigationTitle("Private Positions (\(visible.count))") + .overlay { if visible.isEmpty { ContentUnavailableView("No Records", systemImage: "bookmark") } } + } +} + +// MARK: - PersistentPrivateShieldedAddress + +struct PrivateShieldedAddressStorageListView: View { + let network: Network + @Query private var records: [PersistentPrivateShieldedAddress] + + private var filtered: [PersistentPrivateShieldedAddress] { + records.filter { $0.networkRaw == network.rawValue } + .sorted { ($0.ownerId.toHexString(), $0.contactId.toHexString()) + < ($1.ownerId.toHexString(), $1.contactId.toHexString()) } + } + + var body: some View { + let visible = filtered + List(visible) { record in + NavigationLink(destination: PrivateShieldedAddressStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text("Contact: \(record.contactId.toHexString())") + .font(.body).lineLimit(1).truncationMode(.middle) + Text("Owner: \(record.ownerId.toHexString())") + .font(.caption).foregroundColor(.secondary) + .lineLimit(1).truncationMode(.middle) + } + } + } + .navigationTitle("Private Addresses (\(visible.count))") + .overlay { if visible.isEmpty { ContentUnavailableView("No Records", systemImage: "lock.rectangle") } } + } +} + // MARK: - PersistentDocument struct DocumentStorageListView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 7fe6ed1cd28..29431a3565a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -86,6 +86,51 @@ struct IdentityBalanceMetadataStorageDetailView: View { } } +// MARK: - PersistentPrivateContactRequestsPosition + +struct PrivateContactRequestsPositionStorageDetailView: View { + let record: PersistentPrivateContactRequestsPosition + + var body: some View { + Form { + Section("Identity") { + FieldRow(label: "Network", value: Network(rawValue: record.networkRaw)?.displayName ?? "raw \(record.networkRaw)") + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "Identity ID", value: hexString(record.identityId)) + } + Section("Private Contact Request Store") { + FieldRow(label: "Read Position", value: String(UInt64(bitPattern: record.position))) + FieldRow(label: "Key Fingerprint", value: hexString(record.keyFingerprint)) + FieldRow(label: "Private Payments Unresolved", value: record.privatePaymentsUnresolved ? "Yes" : "No") + } + } + .navigationTitle("Private Position") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - PersistentPrivateShieldedAddress + +struct PrivateShieldedAddressStorageDetailView: View { + let record: PersistentPrivateShieldedAddress + + var body: some View { + Form { + Section("Contact") { + FieldRow(label: "Network", value: Network(rawValue: record.networkRaw)?.displayName ?? "raw \(record.networkRaw)") + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "Owner ID", value: hexString(record.ownerId)) + FieldRow(label: "Contact ID", value: hexString(record.contactId)) + } + Section("Shielded Address") { + FieldRow(label: "Raw Address", value: hexString(record.address)) + } + } + .navigationTitle("Private Address") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentIdentity struct IdentityStorageDetailView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index ec183dfa41e..8dea8c41a2c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -249,6 +249,23 @@ final class WalletManagerStore: ObservableObject { error: error ) } + // Open the shielded store right after the load: Rust then restores, + // with no keys, the unconfirmed private contact request payments of + // every wallet that bound a shielded account in an earlier process. + // Until it does, those wallets' public contact requests are refused + // (code 71), and a later bind may never come (no mnemonic, shielded + // off). The same path the bind opens, so the bind's call is a no-op. + do { + try manager.configureShielded(dbPath: ShieldedService.dbPath(for: network)) + } catch { + SDKLogger.event( + "manager_shielded_configure_failed", + category: .lifecycle, + severity: .error, + fields: ["network": .publicText(network.displayName)], + error: error + ) + } managers[network] = manager managerSdkHandles[network] = sdk.handle if makeActive { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift new file mode 100644 index 00000000000..5de5dd8e489 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -0,0 +1,213 @@ +import XCTest +import SwiftDashSDK +@testable import SwiftExampleApp + +/// Pins how the DashPay views read a failed contact request submission and a +/// private-store refresh: an unconfirmed private send or reply stays pending +/// (no fresh submission is offered), and a network that does not serve the +/// private store yet is quiet while real failures are shown. +final class ContactRequestOutcomesTests: XCTestCase { + + func testShouldKeepAnUnconfirmedPrivateSubmissionPending() { + let outcome = ContactRequestSubmissionOutcome.of( + PlatformWalletError.shieldedSpendUnconfirmed("result could not be confirmed") + ) + XCTAssertEqual(outcome, .mayHaveGoneThrough) + } + + /// A pending private request (code 71), which a public send or accept + /// can meet too, sent nothing: it is shown as pending, with a private + /// sync to settle it, rather than as a submission that may have gone + /// through. + func testShouldKeepAPendingPrivateRequestPending() { + let outcome = ContactRequestSubmissionOutcome.of( + PlatformWalletError.privateContactRequestPending("not yet read back from the store") + ) + XCTAssertEqual(outcome, .privateRequestPending) + } + + func testShouldShowAWalletPendingReplyOverAnEarlierError() { + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage(pending: true, error: "Accept failed: boom"), + ContactRequestSubmissionOutcome.privateRequestPendingMessage + ) + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage(pending: false, error: "Accept failed: boom"), + "Accept failed: boom" + ) + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage( + pending: false, unavailable: true, error: "Accept failed: boom"), + PrivateRepliesPendingSnapshot.unavailableMessage + ) + XCTAssertNil(ContactRequestSubmissionOutcome.rowMessage(pending: false, error: nil)) + } + + /// A read that found a private request pending, followed by a failed + /// read: the contact stays pending and nothing is offered for any + /// contact, until a read succeeds again. + func testShouldKeepThePendingSnapshotAndOfferNothingAfterAFailedRead() { + let pendingContact = Data(repeating: 1, count: 32) + let otherContact = Data(repeating: 2, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record([pendingContact], for: [pendingContact, otherContact]) + XCTAssertTrue(snapshot.isPending(pendingContact)) + XCTAssertFalse(snapshot.allowsSubmission(to: pendingContact)) + XCTAssertTrue(snapshot.allowsSubmission(to: otherContact)) + + snapshot.record(nil, for: [pendingContact, otherContact]) + XCTAssertTrue(snapshot.isUnavailable) + XCTAssertTrue(snapshot.isPending(pendingContact), "the last snapshot stays") + XCTAssertFalse(snapshot.allowsSubmission(to: pendingContact)) + XCTAssertFalse(snapshot.allowsSubmission(to: otherContact)) + + snapshot.record([], for: [pendingContact, otherContact]) + XCTAssertFalse(snapshot.isUnavailable) + XCTAssertTrue(snapshot.allowsSubmission(to: pendingContact)) + } + + /// A send refused as pending (code 71) when the wallet reserved + /// nothing (its store was not read to its end): the following read finds + /// nothing pending, yet the contact stays pending, with the private sync + /// offered, until the wallet's readiness says the store was read. + func testShouldKeepARefusedContactPendingUntilTheReadinessSaysTheStoreWasRead() { + let contact = Data(repeating: 1, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.recordRefusal(of: contact) + snapshot.record([], for: [contact]) + XCTAssertFalse(snapshot.isUnavailable) + XCTAssertTrue(snapshot.isPending(contact)) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage( + pending: snapshot.isPending(contact), error: nil), + ContactRequestSubmissionOutcome.privateRequestPendingMessage) + + // A pending read that finds nothing does not lift it; the wallet's + // readiness saying the store was read to its end does. + snapshot.record([], for: [contact]) + XCTAssertTrue(snapshot.isPending(contact)) + snapshot.recordReadiness(PrivateContactReadiness( + privatePaymentsRestored: true, privateStoreRead: true, unresolvedPrivatePayment: false)) + XCTAssertFalse(snapshot.isPending(contact)) + XCTAssertTrue(snapshot.allowsSubmission(to: contact)) + } + + /// Unrestored private payments (code 72) are their own outcome, apart + /// from a pending request (71), with their own message. + func testShouldTellUnrestoredPrivatePaymentsFromAPendingRequest() { + XCTAssertEqual( + ContactRequestSubmissionOutcome.of( + PlatformWalletError.privateContactPaymentsNotRestored("not restored")), + .privatePaymentsNotRestored) + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage(pending: true, restoring: true, error: nil), + ContactRequestSubmissionOutcome.restoringPrivatePaymentsMessage) + XCTAssertNotEqual( + ContactRequestSubmissionOutcome.restoringPrivatePaymentsMessage, + ContactRequestSubmissionOutcome.privateRequestPendingMessage) + } + + /// A code 72 hold survives a successful pending read that finds nothing, + /// and a readiness read after a private sync that still says not + /// restored; only a readiness read that says restored lifts it. + func testShouldHoldOnCode72UntilTheReadinessSaysRestored() { + let contact = Data(repeating: 1, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.recordRestoringRefusal() + snapshot.record([], for: [contact]) + XCTAssertTrue(snapshot.isRestoringPrivatePayments) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + + // A private sync read the store to its end; the payments are still + // not restored. + snapshot.recordReadiness(PrivateContactReadiness( + privatePaymentsRestored: false, privateStoreRead: true, unresolvedPrivatePayment: true)) + XCTAssertTrue(snapshot.isRestoringPrivatePayments) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + + // A failed readiness read keeps what the last one said. + snapshot.recordReadiness(nil) + XCTAssertTrue(snapshot.isRestoringPrivatePayments) + + snapshot.recordReadiness(PrivateContactReadiness( + privatePaymentsRestored: true, privateStoreRead: true, unresolvedPrivatePayment: false)) + XCTAssertFalse(snapshot.isRestoringPrivatePayments) + XCTAssertTrue(snapshot.allowsSubmission(to: contact)) + } + + /// A readiness that says not restored holds every contact without any + /// refusal, as the wallet would refuse them. + func testShouldShowRestoringFromTheReadinessAlone() { + let contact = Data(repeating: 1, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record([], for: [contact]) + snapshot.recordReadiness(PrivateContactReadiness( + privatePaymentsRestored: false, privateStoreRead: false, unresolvedPrivatePayment: false)) + XCTAssertTrue(snapshot.isRestoringPrivatePayments) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + } + + /// A successful read that found nothing pending offers every contact it + /// covered, and nothing it did not. + func testShouldOfferEveryContactASuccessfulEmptyReadCovered() { + let contact = Data(repeating: 1, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record([], for: [contact]) + XCTAssertFalse(snapshot.isUnavailable) + XCTAssertFalse(snapshot.isPending(contact)) + XCTAssertTrue(snapshot.allowsSubmission(to: contact)) + XCTAssertFalse( + snapshot.allowsSubmission(to: Data(repeating: 2, count: 32)), + "a contact the read did not cover is not offered yet") + } + + func testShouldAcceptACollisionPrivatelyWhenSendPrivatelyIsOn() { + let accept = CollisionAccept.of(sendPrivately: true) + XCTAssertEqual(accept, .privateReply) + XCTAssertEqual(accept.buttonTitle, "Accept privately") + XCTAssertEqual( + accept.message, + "This person already sent you a request. Accept it with a private reply instead? " + + "The reply is paid from this wallet's shielded balance, and no public request " + + "names you as its sender." + ) + } + + func testShouldAcceptACollisionInKindWhenSendPrivatelyIsOff() { + let accept = CollisionAccept.of(sendPrivately: false) + XCTAssertEqual(accept, .replyInKind) + XCTAssertEqual(accept.buttonTitle, "Accept") + XCTAssertEqual(accept.message, "This person already sent you a request. Accept it instead?") + } + + func testShouldOfferARetryForAnOrdinaryFailure() { + let error = PlatformWalletError.shieldedBroadcastFailed("rejected by the relay") + XCTAssertEqual( + ContactRequestSubmissionOutcome.of(error), + .failed(error.localizedDescription) + ) + } + + func testShouldStayQuietWhenTheNetworkDoesNotServeThePrivateStoreYet() { + XCTAssertNil(PrivateContactRefreshNotice.notice( + for: PlatformWalletError.protocolVersionNotActive("needs protocol version 15") + )) + } + + func testShouldShowAPrivateRefreshThatFailed() { + let error = PlatformWalletError.walletOperation("no node answered") + XCTAssertEqual( + PrivateContactRefreshNotice.notice(for: error), + "Private requests could not be read: \(error.localizedDescription)" + ) + } + + func testShouldShowRequestsThatWereReadButNotSaved() { + XCTAssertNil(PrivateContactRefreshNotice.notice(unpersistedIdentityCount: 0)) + XCTAssertEqual( + PrivateContactRefreshNotice.notice(unpersistedIdentityCount: 2), + "Private requests for 2 identities were read but not saved. Pull to refresh again." + ) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index d3c07c9f73f..b07ad5a84f2 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -11,16 +11,19 @@ private final class BridgeFutureMarker { var value: String = "future" init() {} } -private enum BridgeFutureV5: VersionedSchema { - static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } - static var models: [any PersistentModel.Type] { DashSchemaV4.models + [BridgeFutureMarker.self] } +private enum BridgeFutureV6: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(6, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV5.models + [BridgeFutureMarker.self] } } private enum BridgeFuturePlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, BridgeFutureV5.self] } + static var schemas: [any VersionedSchema.Type] { + [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self, BridgeFutureV6.self] + } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), - .custom(fromVersion: DashSchemaV4.self, toVersion: BridgeFutureV5.self, + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self), + .custom(fromVersion: DashSchemaV5.self, toVersion: BridgeFutureV6.self, willMigrate: { context in for wallet in try context.fetch(FetchDescriptor()) { wallet.name = "explicit future transformation" @@ -771,7 +774,7 @@ final class DashLegacySchemaMigrationTests: XCTestCase { func testSkippingV3UsesFixedBridgeThenRegisteredCustomFutureStage() throws { try withStore { url in - let schema = Schema(versionedSchema: BridgeFutureV5.self) + let schema = Schema(versionedSchema: BridgeFutureV6.self) var checkedV3 = false let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) XCTAssertThrowsError(try DashLegacySchemaBridge.open( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index e2ea9489f0a..94ee9120cf3 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -78,8 +78,8 @@ final class DashModelMigrationTests: XCTestCase { XCTAssertTrue(log.contains("route=\"historical-v2-to-v3\"")) XCTAssertTrue(log.contains("source_version=\"2.0.0\"")) XCTAssertTrue(log.contains("source_checksum=\"\(sourceChecksum)\"")) - XCTAssertTrue(log.contains("target_version=\"4.0.0\"")) - XCTAssertTrue(log.contains("route=\"labelled-current-v4\"")) + XCTAssertTrue(log.contains("target_version=\"5.0.0\"")) + XCTAssertTrue(log.contains("route=\"labelled-current-v5\"")) XCTAssertEqual(log.components(separatedBy: "event=store_open_succeeded").count - 1, 2) XCTAssertFalse(log.contains("event=store_open_failed")) XCTAssertFalse(log.contains(directory.path)) @@ -95,7 +95,7 @@ final class DashModelMigrationTests: XCTestCase { let updatedLog = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(updatedLog.contains("route=\"new-store\"")) let failure = try XCTUnwrap(updatedLog.split(separator: "\n").first { $0.contains("event=store_open_failed") }) - XCTAssertTrue(failure.contains("target_version=\"4.0.0\"")) + XCTAssertTrue(failure.contains("target_version=\"5.0.0\"")) XCTAssertTrue(failure.contains("error_code=")) XCTAssertFalse(updatedLog.contains(directory.path)) XCTAssertFalse(updatedLog.contains("private-invalid-store-content")) @@ -106,7 +106,7 @@ final class DashModelMigrationTests: XCTestCase { /// either way. Labels whose route is undecidable without that probe /// (accepted V1 versus the bridge, historical V2 versus a beta layout) /// keep failing closed, leaving the store untouched. - func testCurrentV4RouteNeverDependsOnTheSchemaIdentityProbe() throws { + func testCurrentV5RouteNeverDependsOnTheSchemaIdentityProbe() throws { struct ProbeUnavailable: Error {} let failingProbe: (any VersionedSchema.Type) throws -> DashLegacySchemaBridge.Identity = { _ in throw ProbeUnavailable() @@ -117,7 +117,7 @@ final class DashModelMigrationTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let current = directory.appendingPathComponent("current.store") try autoreleasepool { _ = try DashModelContainer.create(url: current) } - XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["4.0.0"]) + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["5.0.0"]) let plan = try DashModelContainer.migrationPlan( at: current, defaultPlan: DashMigrationPlan.self, identity: failingProbe) XCTAssertTrue(ObjectIdentifier(plan) == ObjectIdentifier(DashMigrationPlan.self)) @@ -513,7 +513,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV4.self) + let v2Schema = Schema(versionedSchema: DashSchemaV5.self) let v2Configuration = ModelConfiguration( "DashKeyLimitsMigrationTest", schema: v2Schema, @@ -590,7 +590,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV4.self) + let v2Schema = Schema(versionedSchema: DashSchemaV5.self) let v2Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", schema: v2Schema, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index f18b44a9fce..e5c8c605a6c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -459,6 +459,111 @@ final class ErrorHandlingTests: XCTestCase { ) } + func testShouldRouteAPrivatelyReceivedRequestByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_REQUEST_NEEDS_PRIVATE_REPLY + ) + XCTAssertEqual(code, .errorPrivateContactRequestNeedsPrivateReply) + XCTAssertEqual(code.rawValue, 68) + let detail = "the contact requests with 9fRYTJZW came or went privately; " + + "reply to or rotate with it privately" + let error = PlatformWalletError(code: code, message: detail) + guard case .privateContactRequestNeedsPrivateReply(let message) = error else { + return XCTFail("lost typed private-reply error") + } + XCTAssertEqual(message, detail) + XCTAssertEqual(error.errorDescription, detail) + } + + func testShouldTellAPrivateReplyOnABuildWithoutShieldedSupportByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_BUILD_REQUIRED + ) + XCTAssertEqual(code, .errorShieldedBuildRequired) + XCTAssertEqual(code.rawValue, 69) + let detail = "this contact request needs a private reply, which is paid " + + "from the shielded pool; this build has no shielded support" + let error = PlatformWalletError(code: code, message: detail) + guard case .shieldedBuildRequired(let message) = error else { + return XCTFail("lost typed shielded-build error") + } + XCTAssertEqual(message, detail) + XCTAssertEqual(error.errorDescription, detail) + } + + func testShouldTellARequestTheNetworkDoesNotServeYetByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PROTOCOL_VERSION_NOT_ACTIVE + ) + XCTAssertEqual(code, .errorProtocolVersionNotActive) + XCTAssertEqual(code.rawValue, 70) + let detail = "get_private_document_entries needs protocol version 15; " + + "the network is proven at protocol version 14" + let error = PlatformWalletError(code: code, message: detail) + guard case .protocolVersionNotActive(let message) = error else { + return XCTFail("lost typed protocol-version error") + } + XCTAssertEqual(message, detail) + } + + func testShouldReportAPendingPrivateRequestByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_REQUEST_PENDING + ) + XCTAssertEqual(code, .errorPrivateContactRequestPending) + XCTAssertEqual(code.rawValue, 71) + let detail = "a private contact request to 9fRYTJZW is in flight or not yet read " + + "back from the store; sync private contact requests to settle it" + let error = PlatformWalletError(code: code, message: detail) + guard case .privateContactRequestPending(let message) = error else { + return XCTFail("lost typed pending error") + } + XCTAssertEqual(message, detail) + } + + /// Unrestored private payments are the wallet's hold, not one contact's + /// pending request: their own code and typed case, apart from 71. + func testShouldReportUnrestoredPrivatePaymentsByTheirOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_PAYMENTS_NOT_RESTORED + ) + XCTAssertEqual(code, .errorPrivateContactPaymentsNotRestored) + XCTAssertEqual(code.rawValue, 72) + let detail = "the wallet's private payments are not restored from the shielded store yet" + let error = PlatformWalletError(code: code, message: detail) + guard case .privateContactPaymentsNotRestored(let message) = error else { + return XCTFail("lost typed not-restored error") + } + XCTAssertEqual(message, detail) + } + + func testShouldReadEveryFieldOfThePrivateContactReadiness() { + var ffi = PrivateContactReadinessFFI() + ffi.private_payments_restored = false + ffi.private_store_read = true + ffi.unresolved_private_payment = true + XCTAssertEqual( + PrivateContactReadiness(ffi: ffi), + PrivateContactReadiness( + privatePaymentsRestored: false, privateStoreRead: true, unresolvedPrivatePayment: true)) + } + + func testShouldReadEveryFieldOfAPrivateContact() { + var ffi = PrivateContactFFI() + ffi.received_privately = true + ffi.has_shielded_address = true + withUnsafeMutableBytes(of: &ffi.shielded_address) { raw in + for i in raw.indices { raw[i] = UInt8(i) } + } + let info = PrivateContactInfo(ffi: ffi) + XCTAssertTrue(info.receivedPrivately) + XCTAssertFalse(info.sentPrivately) + XCTAssertEqual(info.shieldedAddress, Data((0..<43).map { UInt8($0) })) + + ffi.has_shielded_address = false + XCTAssertNil(PrivateContactInfo(ffi: ffi).shieldedAddress) + } + func testShouldPreserveShieldedIdentityDebitPendingFFIResult() { let code = PlatformWalletResultCode( ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_IDENTITY_DEBIT_PENDING diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 7b51fce43d9..bf2d9eade05 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -68,6 +68,10 @@ final class InvitationPersistenceTests: XCTestCase { // may ride this store's rounds — genuinely attested. | PlatformWalletPersistenceCapabilities.dashpayPayments | PlatformWalletPersistenceCapabilities.assetLockOperationIds + // Private contact request scan state: the handler wires the + // persist/load/free trio onto `PersistentPrivateContactRequestsPosition` + // and `PersistentPrivateShieldedAddress`. + | PlatformWalletPersistenceCapabilities.privateContactRequestScanState XCTAssertEqual( capabilities.version, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift new file mode 100644 index 00000000000..e8405ee0b7f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift @@ -0,0 +1,232 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// The private contact request scan state (read positions and private +/// contacts' shielded addresses) round-trips through SwiftData and the +/// persistence extension's C shims. +final class PrivateContactScanStatePersistenceTests: XCTestCase { + private let walletId = Data(repeating: 42, count: 32) + private let owner = Data(repeating: 7, count: 32) + private let contact = Data(repeating: 5, count: 32) + private let otherContact = Data(repeating: 6, count: 32) + + private typealias Position = PlatformWalletPersistenceHandler.PrivateContactRequestsPositionRow + private typealias Address = PlatformWalletPersistenceHandler.PrivateShieldedAddressRow + private typealias AddressKey = PlatformWalletPersistenceHandler.PrivateShieldedAddressKey + + private func makeHandler() throws -> (ModelContainer, PlatformWalletPersistenceHandler) { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + return (container, PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet)) + } + + private func round( + _ handler: PlatformWalletPersistenceHandler, + positions: [Position] = [], + addresses: [Address] = [], + removed: [AddressKey] = [], + cleared: [Data] = [], + success: Bool = true + ) throws { + handler.beginChangeset(walletId: walletId) + try handler.persistPrivateContactScanState( + walletId: walletId, positions: positions, addresses: addresses, + removedAddresses: removed, clearedIdentities: cleared) + XCTAssertEqual(handler.endChangeset(walletId: walletId, success: success), success) + } + + func testShouldStoreReplaceAndRemoveTheScanStateWithItsRound() throws { + let (_, handler) = try makeHandler() + let fingerprint = Data(repeating: 3, count: 32) + try round( + handler, + positions: [.init(identityId: owner, position: 12, keyFingerprint: fingerprint)], + addresses: [ + .init(ownerId: owner, contactId: contact, address: Data(repeating: 9, count: 43)), + .init(ownerId: owner, contactId: otherContact, address: Data(repeating: 8, count: 43)), + ]) + var stored = try handler.loadPrivateContactScanState(walletId: walletId) + XCTAssertEqual(stored.positions, [.init(identityId: owner, position: 12, keyFingerprint: fingerprint)]) + XCTAssertEqual(Set(stored.addresses.map(\.contactId)), [contact, otherContact]) + + // A rewind writes the lower position; a tombstone removes one address. + try round( + handler, + positions: [.init(identityId: owner, position: 0, keyFingerprint: fingerprint)], + removed: [.init(ownerId: owner, contactId: otherContact)]) + stored = try handler.loadPrivateContactScanState(walletId: walletId) + XCTAssertEqual(stored.positions.map(\.position), [0]) + XCTAssertEqual(stored.addresses, [ + .init(ownerId: owner, contactId: contact, address: Data(repeating: 9, count: 43)), + ]) + + // A rolled-back round leaves the stored state as it was. + try round( + handler, + positions: [.init(identityId: owner, position: 40, keyFingerprint: fingerprint)], + success: false) + XCTAssertEqual(try handler.loadPrivateContactScanState(walletId: walletId).positions.map(\.position), [0]) + + // The unresolved-payment marker is stored with the position and + // cleared by a later round. + try round(handler, positions: [.init( + identityId: owner, position: 0, keyFingerprint: fingerprint, privatePaymentsUnresolved: true)]) + XCTAssertEqual( + try handler.loadPrivateContactScanState(walletId: walletId).positions.map(\.privatePaymentsUnresolved), + [true]) + try round(handler, positions: [.init(identityId: owner, position: 0, keyFingerprint: fingerprint)]) + XCTAssertEqual( + try handler.loadPrivateContactScanState(walletId: walletId).positions.map(\.privatePaymentsUnresolved), + [false]) + + // A full-width position survives the signed SQLite column. + try round(handler, positions: [.init(identityId: owner, position: .max, keyFingerprint: fingerprint)]) + XCTAssertEqual(try handler.loadPrivateContactScanState(walletId: walletId).positions.map(\.position), [.max]) + } + + func testShouldClearTheScanStateOfARemovedIdentity() throws { + let (_, handler) = try makeHandler() + let survivor = Data(repeating: 1, count: 32) + try round( + handler, + positions: [ + .init(identityId: owner, position: 12, keyFingerprint: Data(repeating: 3, count: 32)), + .init(identityId: survivor, position: 4, keyFingerprint: Data(repeating: 2, count: 32)), + ], + addresses: [ + .init(ownerId: owner, contactId: contact, address: Data(repeating: 9, count: 43)), + .init(ownerId: survivor, contactId: contact, address: Data(repeating: 1, count: 43)), + ]) + try round(handler, cleared: [owner]) + let stored = try handler.loadPrivateContactScanState(walletId: walletId) + XCTAssertEqual(stored.positions.map(\.identityId), [survivor]) + XCTAssertEqual(stored.addresses.map(\.ownerId), [survivor]) + } + + func testShouldScopeTheScanStateByWalletAndNetworkAndPurgeItWithTheWallet() throws { + let (container, handler) = try makeHandler() + try round( + handler, + positions: [.init(identityId: owner, position: 12, keyFingerprint: Data(repeating: 3, count: 32))], + addresses: [.init(ownerId: owner, contactId: contact, address: Data(repeating: 9, count: 43))]) + XCTAssertTrue(try handler.loadPrivateContactScanState(walletId: Data(repeating: 43, count: 32)) + .positions.isEmpty) + let mainnet = PlatformWalletPersistenceHandler(modelContainer: container, network: .mainnet) + XCTAssertTrue(try mainnet.loadPrivateContactScanState(walletId: walletId).positions.isEmpty) + + try handler.deleteWalletData(walletId: walletId) + let context = ModelContext(container) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) + } + + /// A stored row that cannot cross the ABI fails the load instead of + /// handing back the rest without it: a valid position beside a 42-byte + /// address would otherwise resume past the entry that carried it. + func testShouldFailTheLoadOnAStoredRowThatCannotCrossTheABI() throws { + let (container, handler) = try makeHandler() + try round( + handler, + positions: [.init(identityId: owner, position: 12, keyFingerprint: Data(repeating: 3, count: 32))]) + let context = ModelContext(container) + context.insert(PersistentPrivateShieldedAddress( + networkRaw: Network.testnet.rawValue, walletId: walletId, ownerId: owner, + contactId: contact, address: Data(repeating: 9, count: 42))) + try context.save() + + let ext = handler.makePersistenceCallbacksExtension() + let load = try XCTUnwrap(ext.on_load_private_contact_scan_state_fn) + let context_ = Unmanaged.passUnretained(handler).toOpaque() + var positions: UnsafePointer? + var positionsCount: UInt = 0 + var addresses: UnsafePointer? + var addressesCount: UInt = 0 + let rc = walletId.withUnsafeBytes { wallet in + load(context_, wallet.bindMemory(to: UInt8.self).baseAddress, + &positions, &positionsCount, &addresses, &addressesCount) + } + XCTAssertNotEqual(rc, 0) + XCTAssertNil(positions) + XCTAssertNil(addresses) + XCTAssertEqual(positionsCount + addressesCount, 0) + } + + func testShouldRefuseAScanStateWriteOutsideAChangeset() throws { + let (_, handler) = try makeHandler() + XCTAssertThrowsError(try handler.persistPrivateContactScanState( + walletId: walletId, + positions: [.init(identityId: owner, position: 1, keyFingerprint: Data(repeating: 3, count: 32))], + addresses: [], removedAddresses: [], clearedIdentities: [])) + } + + /// The C shims Rust calls: a persist through the extension's slot is + /// read back through its load slot, and the free slot returns the + /// arrays. The capability is declared alongside them. + func testShouldDeclareTheCapabilityAndRoundTripThroughTheCallbackShims() throws { + let (_, handler) = try makeHandler() + XCTAssertNotEqual( + handler.makePersistenceCapabilities().bits + & PlatformWalletPersistenceCapabilities.privateContactRequestScanState, 0) + XCTAssertEqual(PlatformWalletPersistenceCapabilities.privateContactRequestScanState, 1 << 14) + let ext = handler.makePersistenceCallbacksExtension() + let persist = try XCTUnwrap(ext.on_persist_private_contact_scan_state_fn) + let load = try XCTUnwrap(ext.on_load_private_contact_scan_state_fn) + let free = try XCTUnwrap(ext.on_load_private_contact_scan_state_free_fn) + let context = Unmanaged.passUnretained(handler).toOpaque() + + var position = PrivateContactRequestsPositionFFI() + Swift.withUnsafeMutableBytes(of: &position.identity_id) { _ = owner.copyBytes(to: $0) } + position.position = 77 + position.private_payments_unresolved = true + Swift.withUnsafeMutableBytes(of: &position.key_fingerprint) { + _ = Data(repeating: 3, count: 32).copyBytes(to: $0) + } + var address = PrivateShieldedAddressFFI() + Swift.withUnsafeMutableBytes(of: &address.owner_id) { _ = owner.copyBytes(to: $0) } + Swift.withUnsafeMutableBytes(of: &address.contact_id) { _ = contact.copyBytes(to: $0) } + Swift.withUnsafeMutableBytes(of: &address.address) { _ = Data(repeating: 9, count: 43).copyBytes(to: $0) } + + handler.beginChangeset(walletId: walletId) + let rc = walletId.withUnsafeBytes { wallet in + persist(context, wallet.bindMemory(to: UInt8.self).baseAddress, + &position, 1, &address, 1, nil, 0, nil, 0) + } + XCTAssertEqual(rc, 0) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + + var positions: UnsafePointer? + var positionsCount: UInt = 0 + var addresses: UnsafePointer? + var addressesCount: UInt = 0 + let loadRc = walletId.withUnsafeBytes { wallet in + load(context, wallet.bindMemory(to: UInt8.self).baseAddress, + &positions, &positionsCount, &addresses, &addressesCount) + } + XCTAssertEqual(loadRc, 0) + XCTAssertEqual(positionsCount, 1) + XCTAssertEqual(addressesCount, 1) + let loadedPosition = try XCTUnwrap(positions).pointee + XCTAssertEqual(loadedPosition.position, 77) + XCTAssertTrue(loadedPosition.private_payments_unresolved, "the unresolved marker round-trips") + var loadedOwner = loadedPosition.identity_id + XCTAssertEqual(Swift.withUnsafeBytes(of: &loadedOwner) { Data($0) }, owner) + var loadedAddress = try XCTUnwrap(addresses).pointee.address + XCTAssertEqual(Swift.withUnsafeBytes(of: &loadedAddress) { Data($0) }, Data(repeating: 9, count: 43)) + free(context, positions, positionsCount, addresses, addressesCount) + + // A wallet with nothing stored hands back two empty arrays. + let empty = Data(repeating: 43, count: 32) + let emptyRc = empty.withUnsafeBytes { wallet in + load(context, wallet.bindMemory(to: UInt8.self).baseAddress, + &positions, &positionsCount, &addresses, &addressesCount) + } + XCTAssertEqual(emptyRc, 0) + XCTAssertNil(positions) + XCTAssertNil(addresses) + XCTAssertEqual(positionsCount + addressesCount, 0) + } +} diff --git a/packages/swift-sdk/schema-models.json b/packages/swift-sdk/schema-models.json index 439a181f8e3..b7714b9657d 100644 --- a/packages/swift-sdk/schema-models.json +++ b/packages/swift-sdk/schema-models.json @@ -36,7 +36,9 @@ "PersistentInvitation": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift", "PersistentMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift", "PersistentTrackedMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift", - "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift" + "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift", + "PersistentPrivateContactRequestsPosition": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift", + "PersistentPrivateShieldedAddress": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift" }, "value_types": [ {