From 67355754ad90c05d509d95f42d49948ae38558c1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 12:07:00 +0700 Subject: [PATCH 01/37] feat(rs-sdk-ffi): scan, read and seal private contact requests in the resolver signer `MnemonicResolverCoreSigner` gains the three key operations DashPay private contact requests (protocol version 15) need, in the style of `contact_info_seal` and `ecdh_shared_secret`: each derives the keys it needs at Rust-built paths, wipes its copies, and returns only the result. - `scan_private_document_page` sorts a page of a private document store with the recipients' secret keys and the senders' outgoing viewing keys. - `read_private_contact_requests` reads a scan's requests, checking each sender with the recipient key that opened it. Only recipient keys are derived. - `seal_private_contact_request` seals a request with the sender's encryption key. A scan or a read derives every key from one resolve of the mnemonic, not one Keychain read per key: `resolve_and_derive` is now a one-path case of the new `resolve_master`. The SDK's refusals come back as the new `MnemonicResolverSignerError::PrivateContactRequest`, keeping the `dash_sdk::Error`. Tests pin the signer to a resident wallet of the same seed: the sealed entry is byte for byte the one the SDK seals with the seed-derived key, and a page scans and reads exactly as with seed-derived scan keys. Co-Authored-By: Claude Opus 5.5 --- .../src/mnemonic_resolver_core_signer.rs | 664 +++++++++++++++++- 1 file changed, 642 insertions(+), 22 deletions(-) diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index 5418123ad43..5780948b9fa 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -56,11 +56,24 @@ //! Combined, no private key bytes survive past the trait-method //! boundary. +use std::collections::BTreeMap; use std::ffi::c_void; use std::os::raw::c_char; use async_trait::async_trait; +use dash_sdk::dpp::data_contract::DataContract; +use dash_sdk::dpp::identity::{Identity, KeyID}; +use dash_sdk::dpp::platform_value::BinaryData; +use dash_sdk::dpp::prelude::Identifier; use dash_sdk::dpp::secp256k1; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::dashpay::{ + private_contact_request_outgoing_viewing_key, private_contact_requests_from_scan, + seal_private_contact_request, PrivateContactRequest, PrivateContactRequestScan, + PrivateContactRequestSender, +}; +use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; +use dash_sdk::platform::private_document_scan::{PrivateDocumentScan, PrivateDocumentScanKeys}; use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use key_wallet::dashcore::ecdsa::{EcdsaError, EcdsaPublicKey, EcdsaSecretKey, EcdsaSignature}; use key_wallet::dip9::{ @@ -70,6 +83,7 @@ use key_wallet::dip9::{ }; use key_wallet::signer::{ExtendedPubKeySigner, Signer, SignerMethod}; use key_wallet::Network; +use platform_encryption::PrivateDocumentTarget; use thiserror::Error; use zeroize::Zeroizing; @@ -166,6 +180,12 @@ pub enum MnemonicResolverSignerError { /// Decoding the generated recoverable signature failed. #[error("ECDSA signature conversion failed: {0}")] SignatureConversion(#[source] EcdsaError), + + /// The SDK refused to read or seal a private contact request with the + /// derived keys: a malformed request, a key the identity does not hold, + /// or a contract without the private type. + #[error("private contact request: {0}")] + PrivateContactRequest(dash_sdk::Error), } /// `key_wallet::signer::Signer` implementation that derives ECDSA @@ -242,36 +262,36 @@ impl MnemonicResolverCoreSigner { } } - /// Resolve the mnemonic from the Swift-side callback, derive the BIP-32 - /// extended private key at `path`, and hand it *by reference* to - /// `extract`, returning whatever `extract` produces. + /// Resolve the mnemonic from the Swift-side callback, build the BIP-32 + /// master `ExtendedPrivKey`, and hand it *by reference* to `use_master`, + /// returning whatever `use_master` produces. An operation that needs keys + /// at several paths derives them all from this one resolve. /// /// This is the single entry-point for all private-key material in this /// signer. It handles the full stack: resolver FFI call → result-code /// mapping → UTF-8 + word-list validation → BIP-39 seed → master - /// `ExtendedPrivKey` → child `ExtendedPrivKey` at `path`. + /// `ExtendedPrivKey`. /// /// # Zeroization contract /// - /// Both the `master` and `derived` extended keys wipe their secret - /// material when they leave this scope — [`ExtendedPrivKey`] zeroizes on - /// `Drop` and is not `Copy`, so each move is a real move that leaves no - /// bitwise duplicate behind. The - /// key never crosses the call boundary — `extract` only borrows it — so it - /// cannot outlive the derivation. `extract` returns public material - /// (`ExtendedPubKey`) or a `Zeroizing` scalar copy; the caller wipes the - /// latter on its own drop. The mnemonic and seed buffers are plain arrays - /// and ride [`Zeroizing`] wrappers for the same guarantee. + /// The `master` extended key wipes its secret material when it leaves + /// this scope, as does every child `use_master` derives from it: + /// [`ExtendedPrivKey`] zeroizes on `Drop` and is not `Copy`, so each move + /// is a real move that leaves no bitwise duplicate behind. The master key + /// never crosses the call boundary (`use_master` only borrows it), so it + /// cannot outlive the operation. `use_master` returns public material, a + /// product of the keys, or a `Zeroizing` scalar copy the caller wipes on + /// its own drop. The mnemonic and seed buffers are plain arrays and ride + /// [`Zeroizing`] wrappers for the same guarantee. /// /// # Errors /// /// Propagates [`MnemonicResolverSignerError`] for every failure mode: - /// null handle, resolver FFI errors, encoding/parse failures, and BIP-32 - /// derivation errors. - fn resolve_and_derive( + /// null handle, resolver FFI errors, encoding/parse failures, BIP-32 + /// derivation errors, and whatever `use_master` returns. + fn resolve_master( &self, - path: &DerivationPath, - extract: impl FnOnce(&ExtendedPrivKey) -> T, + use_master: impl FnOnce(&ExtendedPrivKey) -> Result, ) -> Result { if self.resolver_addr == 0 { return Err(MnemonicResolverSignerError::NullHandle); @@ -331,11 +351,25 @@ impl MnemonicResolverCoreSigner { let master = ExtendedPrivKey::new_master(self.network, seed.as_ref()) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")))?; - let derived = master - .derive_priv(path) - .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; - Ok(extract(&derived)) + use_master(&master) + } + + /// Resolve the mnemonic, derive the BIP-32 extended private key at + /// `path`, and hand it *by reference* to `extract`, returning whatever + /// `extract` produces. The derived key self-wipes on `Drop` when this + /// returns; see [`Self::resolve_master`] for the full contract. + fn resolve_and_derive( + &self, + path: &DerivationPath, + extract: impl FnOnce(&ExtendedPrivKey) -> T, + ) -> Result { + self.resolve_master(|master| { + let derived = master + .derive_priv(path) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; + Ok(extract(&derived)) + }) } /// Resolve the mnemonic and derive the raw 32-byte scalar at `path`. @@ -583,6 +617,137 @@ impl MnemonicResolverCoreSigner { private_data, }) } + + /// Sort `page`, read from `start_position` of the store of the private + /// document type `document_type_name` of `data_contract_id`, with the + /// wallet's keys ([`PrivateDocumentScanKeys::scan_page`]). `recipients` + /// are the identities entries may be sealed to, each with the paths of + /// its keys by key id; `senders` the identities that may have sealed + /// entries, each with the path of an encryption key whose outgoing + /// viewing key recovers what it sent. Every key comes from one resolve + /// and is dropped here; only the sorted entries return. + pub fn scan_private_document_page( + &self, + recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], + senders: &[(Identifier, DerivationPath)], + data_contract_id: Identifier, + document_type_name: &str, + start_position: u64, + page: &PrivateDocumentEntries, + ) -> Result { + let target = PrivateDocumentTarget { + contract_id: data_contract_id.to_buffer(), + document_type_name, + }; + self.resolve_master(|master| { + let keys = private_document_scan_keys(master, recipients, senders)?; + Ok(keys.scan_page(&target, start_position, page)) + }) + } + + /// Read the private contact requests of `scan`, a scan of DashPay's + /// store made with the keys at `recipients` + /// ([`private_contact_requests_from_scan`]): each received request's + /// sender is checked with the recipient key that opened it, against the + /// key `sender_identities` holds under the id it names. Only the + /// recipient keys are derived, from one resolve, and dropped here. + pub fn read_private_contact_requests( + &self, + recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], + dashpay_contract: &DataContract, + scan: PrivateDocumentScan, + sender_identities: &BTreeMap>, + platform_version: &PlatformVersion, + ) -> Result { + self.resolve_master(|master| { + let keys = private_document_scan_keys(master, recipients, &[])?; + private_contact_requests_from_scan( + dashpay_contract, + scan, + &keys, + sender_identities, + platform_version, + ) + .map_err(MnemonicResolverSignerError::PrivateContactRequest) + }) + } + + /// Seal `request` from `sender`, whose encryption key + /// `sender_encryption_key_id` is the one at `sender_encryption_key_path`, + /// to the key `recipient_key_id` of `recipient` + /// ([`seal_private_contact_request`]). `esk_seed` is 32 fresh random + /// bytes, never reused. The encryption key, and the outgoing viewing key + /// the SDK derives from it, stay here; only the sealed entry returns. + #[allow(clippy::too_many_arguments)] + pub fn seal_private_contact_request( + &self, + sender_encryption_key_path: &DerivationPath, + dashpay_contract: &DataContract, + sender: &Identity, + sender_encryption_key_id: KeyID, + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + esk_seed: &[u8; 32], + ) -> Result { + let secret = + self.resolve_master(|master| secret_key_at(master, sender_encryption_key_path))?; + seal_private_contact_request( + dashpay_contract, + &PrivateContactRequestSender { + identity: sender, + encryption_key_id: sender_encryption_key_id, + encryption_secret_key: &secret.0, + }, + recipient, + recipient_key_id, + request, + esk_seed, + ) + .map_err(MnemonicResolverSignerError::PrivateContactRequest) + } +} + +/// The secp256k1 secret key at `path` below `master`, wiped on drop. The +/// child `ExtendedPrivKey` self-wipes when this returns. +fn secret_key_at( + master: &ExtendedPrivKey, + path: &DerivationPath, +) -> Result { + let derived = master + .derive_priv(path) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; + let secret = secp256k1::SecretKey::from_secret_bytes(*derived.private_key.to_bytes()) + .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?; + Ok(WipingSecretKey(secret)) +} + +/// The scan keys of `recipients` and `senders`, derived below `master`: each +/// recipient's secret keys, and each sender's outgoing viewing key +/// ([`private_contact_request_outgoing_viewing_key`]). The copies made here +/// are wiped before this returns. +fn private_document_scan_keys( + master: &ExtendedPrivKey, + recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], + senders: &[(Identifier, DerivationPath)], +) -> Result { + let mut keys = PrivateDocumentScanKeys::new(); + for (identity_id, paths) in recipients { + let mut secret_keys = WipingSecretKeys(Vec::with_capacity(paths.len())); + for (key_id, path) in paths { + secret_keys + .0 + .push((*key_id, secret_key_at(master, path)?.0)); + } + keys = keys.with_recipient(*identity_id, &secret_keys.0); + } + for (identity_id, path) in senders { + let secret = secret_key_at(master, path)?; + let outgoing_viewing_key = + Zeroizing::new(private_contact_request_outgoing_viewing_key(&secret.0)); + keys = keys.with_sender(*identity_id, *outgoing_viewing_key); + } + Ok(keys) } /// Result of [`MnemonicResolverCoreSigner::contact_info_seal`]. @@ -610,6 +775,17 @@ impl Drop for WipingSecretKey { } } +/// Scrubs the secret keys of one scan recipient on every exit path. +struct WipingSecretKeys(Vec<(KeyID, secp256k1::SecretKey)>); + +impl Drop for WipingSecretKeys { + fn drop(&mut self) { + for (_, secret_key) in &mut self.0 { + secret_key.non_secure_erase(); + } + } +} + #[async_trait] impl Signer for MnemonicResolverCoreSigner { type Error = MnemonicResolverSignerError; @@ -1226,4 +1402,448 @@ mod tests { assert_eq!(methods.len(), 1); assert!(matches!(methods[0], SignerMethod::Digest)); } + + mod private_contact_requests { + use super::*; + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dash_sdk::dpp::identity::{ + IdentityPublicKey, IdentityV0, KeyType, Purpose, SecurityLevel, + }; + use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dash_sdk::platform::dashpay::PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME; + use dash_sdk::platform::private_document_entries::PrivateDocumentEntry; + use key_wallet::mnemonic::Mnemonic; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const SENDER_ID: [u8; 32] = [0xA0; 32]; + const RECIPIENT_ID: [u8; 32] = [0xB0; 32]; + /// Two identities of another wallet, whose entry the scan passes over. + const OUTSIDER_ID: [u8; 32] = [0xC0; 32]; + const CAROL_ID: [u8; 32] = [0xD0; 32]; + const SENDER_KEY_ID: KeyID = 1; + const RECIPIENT_KEY_ID: KeyID = 2; + const BLOCK_TIME_MS: u64 = 1_700_000_000_000; + + /// The sender's DashPay encryption key, in the test wallet. + fn sender_key_path() -> DerivationPath { + DerivationPath::from_str("m/9'/1'/5'/0'/0'/0'/1'").expect("valid path") + } + + /// The recipient's DashPay decryption key, in the test wallet. + fn recipient_key_path() -> DerivationPath { + DerivationPath::from_str("m/9'/1'/5'/0'/0'/1'/2'").expect("valid path") + } + + /// The secret key a resident wallet of `ENGLISH_PHRASE` holds at `path`. + fn seed_secret_key(path: &DerivationPath) -> secp256k1::SecretKey { + let seed = Mnemonic::from_phrase(ENGLISH_PHRASE) + .expect("valid mnemonic") + .to_seed(""); + let wallet = Wallet::from_seed_bytes( + *seed, + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("seeded wallet"); + let xprv = wallet + .derive_extended_private_key(path) + .expect("wallet derives the private key at path"); + secp256k1::SecretKey::from_secret_bytes(*xprv.private_key.to_bytes()) + .expect("a valid scalar") + } + + fn outsider_secret_key() -> secp256k1::SecretKey { + secp256k1::SecretKey::from_secret_bytes([0x41; 32]).expect("a valid scalar") + } + + fn key( + id: KeyID, + purpose: Purpose, + secret_key: &secp256k1::SecretKey, + ) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new( + secp256k1::PublicKey::from_secret_key(secret_key) + .serialize() + .to_vec(), + ), + disabled_at: None, + }) + } + + fn identity( + id: [u8; 32], + key_id: KeyID, + purpose: Purpose, + secret_key: &secp256k1::SecretKey, + ) -> Identity { + let key = key(key_id, purpose, secret_key); + Identity::V0(IdentityV0 { + id: Identifier::from(id), + public_keys: BTreeMap::from([(key_id, key)]), + balance: 0, + revision: 0, + }) + } + + fn sender() -> Identity { + identity( + SENDER_ID, + SENDER_KEY_ID, + Purpose::ENCRYPTION, + &seed_secret_key(&sender_key_path()), + ) + } + + fn recipient() -> Identity { + identity( + RECIPIENT_ID, + RECIPIENT_KEY_ID, + Purpose::DECRYPTION, + &seed_secret_key(&recipient_key_path()), + ) + } + + fn outsider() -> Identity { + identity( + OUTSIDER_ID, + SENDER_KEY_ID, + Purpose::ENCRYPTION, + &outsider_secret_key(), + ) + } + + fn carol() -> Identity { + identity( + CAROL_ID, + RECIPIENT_KEY_ID, + Purpose::DECRYPTION, + &secp256k1::SecretKey::from_secret_bytes([0x42; 32]).expect("a valid scalar"), + ) + } + + fn dashpay() -> DataContract { + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("DashPay loads at the latest version") + } + + fn target(contract: &DataContract) -> PrivateDocumentTarget<'static> { + PrivateDocumentTarget { + contract_id: contract.id().to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + } + } + + fn request() -> PrivateContactRequest { + PrivateContactRequest { + account_reference: 7, + extended_public_key: [0x33; platform_encryption::COMPACT_XPUB_LEN], + account_label: Some("Alice".to_string()), + auto_accept_proof: None, + shielded_address: Some([0x55; 43]), + } + } + + /// `request` sealed by `sender` with `secret_key`, the key a resident + /// wallet holds, to the decryption key of `recipient`. + fn seed_sealed( + contract: &DataContract, + sender: &Identity, + secret_key: &secp256k1::SecretKey, + recipient: &Identity, + esk_seed: u8, + ) -> BinaryData { + seal_private_contact_request( + contract, + &PrivateContactRequestSender { + identity: sender, + encryption_key_id: SENDER_KEY_ID, + encryption_secret_key: secret_key, + }, + recipient, + RECIPIENT_KEY_ID, + &request(), + &[esk_seed; 32], + ) + .expect("the request seals") + } + + /// The test entry: the sender's request to the recipient. + fn sent_to_recipient(contract: &DataContract, esk_seed: u8) -> BinaryData { + seed_sealed( + contract, + &sender(), + &seed_secret_key(&sender_key_path()), + &recipient(), + esk_seed, + ) + } + + fn page(entries: Vec) -> PrivateDocumentEntries { + PrivateDocumentEntries { + total_count: entries.len() as u64, + entries: entries + .into_iter() + .zip(0u64..) + .map(|(data, position)| PrivateDocumentEntry { + position, + block_height: 10 + position, + block_time_ms: BLOCK_TIME_MS, + data: data.to_vec(), + }) + .collect(), + } + } + + /// The wallet's recipient key paths: the recipient's decryption key. + fn recipient_paths() -> Vec<(Identifier, Vec<(KeyID, DerivationPath)>)> { + vec![( + Identifier::from(RECIPIENT_ID), + vec![(RECIPIENT_KEY_ID, recipient_key_path())], + )] + } + + /// The wallet's sender key paths: the sender's encryption key. + fn sender_paths() -> Vec<(Identifier, DerivationPath)> { + vec![(Identifier::from(SENDER_ID), sender_key_path())] + } + + /// The scan keys a resident wallet of the same seed builds. + fn seed_scan_keys() -> PrivateDocumentScanKeys { + PrivateDocumentScanKeys::new() + .with_recipient( + Identifier::from(RECIPIENT_ID), + &[(RECIPIENT_KEY_ID, seed_secret_key(&recipient_key_path()))], + ) + .with_sender( + Identifier::from(SENDER_ID), + private_contact_request_outgoing_viewing_key(&seed_secret_key( + &sender_key_path(), + )), + ) + } + + unsafe extern "C" fn counting_resolve( + ctx: *const c_void, + wallet_id_bytes: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + ) -> i32 { + (*(ctx as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); + english_resolve(ctx, wallet_id_bytes, out_buf, out_capacity, out_len) + } + + /// The entry the signer seals is byte for byte the one a resident + /// wallet seals with the key at the same path, so a request sent + /// through the Keychain opens and authenticates for every client. + #[test] + fn should_seal_the_entry_a_seed_derived_sender_key_seals() { + let contract = dashpay(); + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let sealed = signer + .seal_private_contact_request( + &sender_key_path(), + &contract, + &sender(), + SENDER_KEY_ID, + &recipient(), + RECIPIENT_KEY_ID, + &request(), + &[7; 32], + ) + .expect("the signer seals the request"); + assert_eq!(sealed, sent_to_recipient(&contract, 7)); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// A page scanned and read through the signer sorts and reads as with + /// a resident wallet's scan keys: the request to the recipient, with + /// its sender checked, and the same request recovered as sent by the + /// sender's outgoing viewing key, while another wallet's entry is + /// passed over. + #[test] + fn should_scan_and_read_a_page_as_seed_derived_scan_keys_do() { + let contract = dashpay(); + let target = target(&contract); + let page = page(vec![ + seed_sealed(&contract, &outsider(), &outsider_secret_key(), &carol(), 1), + sent_to_recipient(&contract, 2), + ]); + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let scan = signer + .scan_private_document_page( + &recipient_paths(), + &sender_paths(), + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + &page, + ) + .expect("the signer scans the page"); + let seed_keys = seed_scan_keys(); + let expected_scan = seed_keys.scan_page(&target, 0, &page); + assert_eq!(scan, expected_scan); + assert_eq!(scan.received.len(), 1, "the request to the recipient"); + assert_eq!(scan.sent.len(), 1, "the request the sender sent"); + assert!(scan.rejected.is_empty()); + assert!(scan.is_complete()); + + let sender_identities = BTreeMap::from([(Identifier::from(SENDER_ID), Some(sender()))]); + let read = signer + .read_private_contact_requests( + &recipient_paths(), + &contract, + scan, + &sender_identities, + PlatformVersion::latest(), + ) + .expect("the signer reads the requests"); + let expected_read = private_contact_requests_from_scan( + &contract, + expected_scan, + &seed_keys, + &sender_identities, + PlatformVersion::latest(), + ) + .expect("the resident wallet reads the requests"); + assert_eq!(read, expected_read); + assert_eq!(read.received.len(), 1); + assert_eq!(read.received[0].sender_id, sender().id()); + assert_eq!(read.received[0].sender_key_id, SENDER_KEY_ID); + assert_eq!(read.received[0].recipient_key_id, RECIPIENT_KEY_ID); + assert_eq!(read.received[0].request, request()); + assert_eq!(read.sent.len(), 1); + assert_eq!(read.sent[0].recipient_id, recipient().id()); + assert!(read.rejected.is_empty()); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// Every key a scan or a read needs comes from one Keychain read, not + /// one per key. + #[test] + fn should_resolve_the_mnemonic_once_per_scan_and_once_per_read() { + let contract = dashpay(); + let page = page(vec![sent_to_recipient(&contract, 3)]); + let resolves = AtomicUsize::new(0); + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create( + &resolves as *const AtomicUsize as *mut c_void, + counting_resolve, + noop_destroy, + ) + }; + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + // Two keys for the recipient and one sender: three derivations. + let recipients = vec![( + Identifier::from(RECIPIENT_ID), + vec![ + (RECIPIENT_KEY_ID, recipient_key_path()), + ( + 3, + DerivationPath::from_str("m/9'/1'/5'/0'/0'/1'/3'").expect("valid path"), + ), + ], + )]; + + let scan = signer + .scan_private_document_page( + &recipients, + &sender_paths(), + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + &page, + ) + .expect("the signer scans the page"); + assert_eq!(resolves.load(Ordering::SeqCst), 1); + + let sender_identities = BTreeMap::from([(Identifier::from(SENDER_ID), Some(sender()))]); + signer + .read_private_contact_requests( + &recipients, + &contract, + scan, + &sender_identities, + PlatformVersion::latest(), + ) + .expect("the signer reads the requests"); + assert_eq!(resolves.load(Ordering::SeqCst), 2); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// A path that is not the sender's encryption key derives a key the + /// SDK refuses to seal with, and the refusal reaches the caller typed. + #[test] + fn should_refuse_to_seal_with_a_key_the_sender_does_not_hold() { + let contract = dashpay(); + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let err = signer + .seal_private_contact_request( + &recipient_key_path(), + &contract, + &sender(), + SENDER_KEY_ID, + &recipient(), + RECIPIENT_KEY_ID, + &request(), + &[7; 32], + ) + .expect_err("the key at that path is not the sender's"); + assert!( + matches!(err, MnemonicResolverSignerError::PrivateContactRequest(_)), + "expected the SDK's refusal, got: {err:?}" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[test] + fn should_report_a_missing_mnemonic_from_a_scan() { + let contract = dashpay(); + let resolver = make_resolver(missing_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let err = signer + .scan_private_document_page( + &recipient_paths(), + &sender_paths(), + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + &page(Vec::new()), + ) + .expect_err("no mnemonic, no keys"); + assert!( + matches!(err, MnemonicResolverSignerError::NotFound), + "error should be NotFound, got: {err:?}" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + } } From 65ebad234511261da44bb215d6702ef0e55b94ee Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 12:07:00 +0700 Subject: [PATCH 02/37] feat(platform-wallet): expose DashPay private contact requests over the FFI The resolver-backed `ContactCryptoProvider` implements the three private contact request methods by delegating to `MnemonicResolverCoreSigner`, so the keys stay in the signer. New C exports: - `platform_wallet_sync_private_contact_requests(wallet_handle, core_signer_handle, max_pages, out_requests, out_report)` runs `DashPayView::sync_private_contact_requests` and returns the requests it ingested with a `PrivateContactSyncReportFFI`. The wallet is looked up under the registry guard and the pass runs outside it. - `platform_wallet_manager_send_private_contact_request` and `platform_wallet_manager_accept_private_contact_request` (`shielded` feature) pay from an account's shielded notes, with the shielded spend contract: panics and results go through `catch_spend_panic` and `map_spend_result`, as the other note-spending exports do. - `managed_identity_get_private_contact` reads `PrivateContact`: whether requests came or went privately, and the contact's shielded address. `PrivateContactRequestNeedsPrivateReply` maps to the new result code `ErrorPrivateContactRequestNeedsPrivateReply` (68), so a host routes a privately received request to the private accept. 61 to 67 are already held by open branches; the registry records the claim and those holds. The auto-accept proof copy moved into `read_auto_accept_proof`, shared by the public and private sends. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 9 +- .../rs-platform-wallet-ffi/src/dashpay.rs | 137 +++- packages/rs-platform-wallet-ffi/src/error.rs | 31 + packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../src/private_contact_request.rs | 587 ++++++++++++++++++ .../src/shielded_send.rs | 6 +- 6 files changed, 748 insertions(+), 24 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/private_contact_request.rs diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 211a7d709ca..1909407eccf 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 61** — 27–60 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 69**: 27–68 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister @@ -123,7 +123,11 @@ shield debits and durable recovery errors; 58 proposed by #4799 for an unavailab identity balance response; 59 proposed by #5322 for an SPV teardown that needs a process restart; 60 proposed by the pooled Core-to-shielded wallet drain for `ErrorAssetLockTooManyInputs` (Rust, Swift and Kotlin mirrors — -renumbered from 59 when #5322 took that number on `v5.1-dev`); 28, 30, +renumbered from 59 when #5322 took that number on `v5.1-dev`); 61–67 held +by open branches with no row here yet (61 `ErrorAssetLockRecipientMismatch`, +62 both `ErrorShieldedClaimBindingMismatch` and `ErrorIdentityRevisionMismatch`, +63–66 the invitation-claim preflight, 67 `ErrorShieldedTreeInconsistent`); 68 +proposed for DashPay private contact requests; 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -177,6 +181,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding | | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — an asset lock needs more inputs than one may carry (100 on Dash Core relay policy and Platform consensus): the full drain, or since #5712 a fixed amount held only in many small coins (identity registration, top-up, invitation, platform-address funding). Two meanings. On a build: nothing was broadcast, the reservation is released, no partial drain, and a rebuild after consolidating is safe. On the resume of a tracked `Built` / `Broadcast` lock over the cap (since #5714): it is not re-sent, but an earlier attempt may have reached peers; the row stays tracked and nothing is released. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | +| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The public accept refused a contact request that came privately (protocol version 15): a `contactRequest` document in reply would publish the relationship. Nothing was sent; the host accepts it with `platform_wallet_manager_accept_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer | **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/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 85dde7ca6ea..b7ed4f9fb4a 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -32,11 +32,22 @@ //! established contact via their `DashpayExternalAccount`. Runs //! the Core transaction path internally. +use std::collections::BTreeMap; use std::ffi::CStr; use std::os::raw::c_char; -use platform_wallet::ContactRequest; -use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; +use dash_sdk::platform::dashpay::{PrivateContactRequest, PrivateContactRequestScan}; +use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; +use dash_sdk::platform::private_document_scan::PrivateDocumentScan; +use dpp::identity::{Identity, KeyID}; +use dpp::platform_value::BinaryData; +use dpp::prelude::{DataContract, Identifier}; +use dpp::version::PlatformVersion; +use platform_wallet::{ContactRequest, PlatformWalletError, PrivateDocumentKeyPaths}; +use rs_sdk_ffi::{ + MnemonicResolverCoreSigner, MnemonicResolverHandle, MnemonicResolverSignerError, SignerHandle, + VTableSigner, +}; use crate::contact_request::CONTACT_REQUEST_STORAGE; use crate::core_wallet::classify_broadcast_result; @@ -179,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(); } @@ -314,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; @@ -369,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 @@ -1222,6 +1245,82 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { private_data: opened.private_data, }) } + + async fn scan_private_document_page( + &self, + keys: &PrivateDocumentKeyPaths, + data_contract_id: Identifier, + document_type_name: &str, + start_position: u64, + page: &PrivateDocumentEntries, + ) -> Result { + self.signer + .scan_private_document_page( + &keys.recipients, + &keys.senders, + data_contract_id, + document_type_name, + start_position, + page, + ) + .map_err(private_document_signer_error) + } + + async fn read_private_contact_requests( + &self, + keys: &PrivateDocumentKeyPaths, + dashpay_contract: &DataContract, + scan: PrivateDocumentScan, + sender_identities: &BTreeMap>, + platform_version: &PlatformVersion, + ) -> Result { + self.signer + .read_private_contact_requests( + &keys.recipients, + dashpay_contract, + scan, + sender_identities, + platform_version, + ) + .map_err(private_document_signer_error) + } + + async fn seal_private_contact_request( + &self, + sender_encryption_key_path: &key_wallet::bip32::DerivationPath, + dashpay_contract: &DataContract, + sender: &Identity, + sender_encryption_key_id: KeyID, + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + esk_seed: &[u8; 32], + ) -> Result { + self.signer + .seal_private_contact_request( + sender_encryption_key_path, + dashpay_contract, + sender, + sender_encryption_key_id, + recipient, + recipient_key_id, + request, + esk_seed, + ) + .map_err(private_document_signer_error) + } +} + +/// A signer failure of a private document operation as a wallet error: the +/// SDK's refusal stays typed, the rest are reported as the other provider +/// methods report theirs. +fn private_document_signer_error(error: MnemonicResolverSignerError) -> PlatformWalletError { + match error { + MnemonicResolverSignerError::PrivateContactRequest(error) => { + PlatformWalletError::Sdk(error) + } + error => PlatformWalletError::InvalidIdentityData(error.to_string()), + } } /// Drain the persisted deferred-crypto queue using the Keychain signer for the diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 762a8e35668..75958a5441a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -622,6 +622,14 @@ pub enum PlatformWalletFFIResultCode { /// coins lets either lock fit. ErrorAssetLockTooManyInputs = 60, + /// Maps `PlatformWalletError::PrivateContactRequestNeedsPrivateReply`: + /// the contact request came privately (protocol version 15), so a public + /// `contactRequest` reply would publish the relationship. Nothing was + /// sent. Accept it with + /// `platform_wallet_manager_accept_private_contact_request` instead. + /// 61 to 67 are claimed by open branches, so this takes 68. + ErrorPrivateContactRequestNeedsPrivateReply = 68, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1059,6 +1067,11 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::SpvProcessRestartRequired(..) => { PlatformWalletFFIResultCode::ErrorSpvProcessRestartRequired } + // The public accept refused a request that came privately; the + // host routes it to the private accept. + PlatformWalletError::PrivateContactRequestNeedsPrivateReply(..) => { + PlatformWalletFFIResultCode::ErrorPrivateContactRequestNeedsPrivateReply + } // A signer failure can also reach this blanket impl wrapped as // `PlatformWalletError::Sdk(dash_sdk::Error::Protocol(..))` (any // wallet operation that propagates the SDK error via `?`). The @@ -2692,3 +2705,21 @@ mod identity_balance_error_tests { assert!(!result.message.is_null()); } } + +#[cfg(test)] +mod private_contact_request_error_tests { + use super::*; + + #[test] + fn should_tell_a_private_request_from_other_accept_failures_by_code() { + let result = PlatformWalletFFIResult::from( + PlatformWalletError::PrivateContactRequestNeedsPrivateReply([7; 32].into()), + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorPrivateContactRequestNeedsPrivateReply + ); + assert_eq!(result.code as u32, 68); + assert!(!result.message.is_null()); + } +} 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/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs new file mode 100644 index 00000000000..3adb8a69ca0 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -0,0 +1,587 @@ +//! 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. Every key stays in +//! the resolver-backed signer. +//! - [`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. +//! Built with the `shielded` feature only. +//! - [`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. +//! +//! A request that came privately is refused by the public accept with +//! `ErrorPrivateContactRequestNeedsPrivateReply`; the host routes it to the +//! private accept. + +use platform_wallet::PrivateContact; +use rs_sdk_ffi::MnemonicResolverHandle; + +use crate::dashpay::{resolver_contact_crypto_provider, ContactRequestHandleArray}; +use crate::error::*; +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(feature = "shielded")] +use std::ffi::CStr; +#[cfg(feature = "shielded")] +use std::os::raw::c_char; + +#[cfg(feature = "shielded")] +use platform_wallet::wallet::shielded::CachedOrchardProver; +#[cfg(feature = "shielded")] +use platform_wallet::AutoAcceptProofSource; + +#[cfg(feature = "shielded")] +use crate::contact_request::CONTACT_REQUEST_STORAGE; +#[cfg(feature = "shielded")] +use crate::dashpay::read_auto_accept_proof; +#[cfg(feature = "shielded")] +use crate::established_contact::ESTABLISHED_CONTACT_STORAGE; +#[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_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; + +/// 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, +} + +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 +/// and senders are checked with keys derived inside it. 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 = unwrap_result_or_return!(result); + 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], +} + +impl PrivateContactFFI { + fn empty() -> Self { + Self { + received_privately: false, + sent_privately: false, + has_shielded_address: false, + shielded_address: [0; SHIELDED_ADDRESS_LEN], + } + } +} + +impl From<&PrivateContact> 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 no request between them +/// went privately, as far as this process has seen: the record is in memory +/// only and rebuilt by `platform_wallet_sync_private_contact_requests`. +/// +/// # 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() +} + +/// Copy an optional Orchard address: null means none. +/// +/// # Safety +/// A non-null `ptr` must point to [`SHIELDED_ADDRESS_LEN`] readable bytes. +#[cfg(feature = "shielded")] +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. 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. +/// - `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, + 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, + 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, + out_request_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_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 proof runs on a worker thread: Halo 2 synthesis recurses past the + // iOS dispatch-thread stack. + let result = block_on_worker(async move { + 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, + &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) => 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. 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. +/// - `out_established_handle` must be a valid, writable pointer. +#[cfg(feature = "shielded")] +#[no_mangle] +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, + 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, + 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")] +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, + out_established_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_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 result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + let result = wallet + .accept_private_contact_request( + &coordinator, + seed.as_ref(), + account, + &request, + shielded_address, + &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) => map_spend_result(Err(error), "private contact request accept"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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); + } + + #[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 232d8a9a9c8..0564ad299fc 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 { .. }) @@ -596,7 +596,7 @@ fn poke_sync_on_unconfirmed(result: &Result, handle: /// withdraw) to a typed FFI result, mirroring the identity-create sibling's /// code split so hosts can tell "definitively failed, safe to retry" from /// "may have executed, do NOT retry". -fn map_spend_result( +pub(crate) fn map_spend_result( result: Result<(), PlatformWalletError>, operation: &str, ) -> PlatformWalletFFIResult { @@ -743,7 +743,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 { From cc555f84088043835ba66e83fc64586609870121 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 12:07:00 +0700 Subject: [PATCH 03/37] feat(swift-sdk): sync, send and accept DashPay private contact requests Thin wrappers over the new platform-wallet FFI: - `ManagedPlatformWallet.syncPrivateContactRequests(maxPages:)` returns a `PrivateContactSyncResult`. - `PlatformWalletManager.sendPrivateContactRequest(...)` and `acceptPrivateContactRequest(...)` pay from the wallet's shielded notes, taking the resolver as the other shielded calls do. - `ManagedIdentity.privateContact(contactId:)` returns `PrivateContactInfo` (came privately, went privately, shielded address), or nil. `PlatformWalletResultCode.errorPrivateContactRequestNeedsPrivateReply` (68) and `PlatformWalletError.privateContactRequestNeedsPrivateReply` carry the public accept's refusal, pinned in `ErrorHandlingTests`. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/PlatformWalletResult.swift | 12 + .../PrivateContactRequests.swift | 275 ++++++++++++++++++ .../ErrorHandlingTests.swift | 32 ++ 3 files changed, 319 insertions(+) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index b8a2f107ad7..2c9abc26201 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -265,6 +265,9 @@ 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 request came privately, so a public reply would publish + /// the relationship. Nothing was sent; accept it privately. + case errorPrivateContactRequestNeedsPrivateReply = 68 /// 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 +399,8 @@ 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_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -587,6 +592,10 @@ 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 request came privately (protocol version 15): a public + /// reply would publish the relationship, so nothing was sent. Accept it + /// with `PlatformWalletManager.acceptPrivateContactRequest`. + case privateContactRequestNeedsPrivateReply(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -812,6 +821,7 @@ public enum PlatformWalletError: LocalizedError { .assetLockNotTracked(let m), .assetLockAlreadyConsumed(let m), .assetLockFundingMismatch(let m), .assetLockInsufficientFunds(let m), .assetLockTooManyInputs(let m), + .privateContactRequestNeedsPrivateReply(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -930,6 +940,8 @@ 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 .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..d4337dc486f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -0,0 +1,275 @@ +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. +// The wallet records it as an ordinary `ContactRequest`; what that type has no +// field for (that it came privately, and the contact's shielded address) is +// read with `ManagedIdentity.privateContact(contactId:)`. +// +// Routing an accept: the public `acceptContactRequest` throws +// `PlatformWalletError.privateContactRequestNeedsPrivateReply` for a request +// that came privately; accept it with +// `PlatformWalletManager.acceptPrivateContactRequest` instead. + +/// What an identity's private contact requests with one contact carry beyond +/// the `ContactRequest`. Rust keeps it in memory only and rebuilds it with +/// `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. + 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 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 { + /// What this identity's private contact requests with `contactId` carry, + /// or `nil` when none between them went privately as far as this process + /// has seen. + 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) + } +} + +extension ManagedPlatformWallet { + /// 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 keys stay in the Keychain mnemonic resolver Rust-side. The store + /// cursors live in memory only, so the first pass after launch reads the + /// store from its start, and until it has run the public accept cannot + /// tell that a request came privately. + 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 + ) + let requestHandle: Handle = try await Task.detached(priority: .userInitiated) { + () -> Handle in + var outHandle: Handle = NULL_HANDLE + let resolverHandle = resolver.handle + // Guaranteed resolver keepalive across the FFI call: same + // rationale as `shieldedTransfer`. + try withExtendedLifetime(resolver) { + 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, + &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. + public func acceptPrivateContactRequest( + walletId: Data, + resolver: MnemonicResolver, + account: UInt32 = 0, + request: ContactRequest, + shieldedAddress: Data? = nil + ) async throws -> EstablishedContact { + let handle = try privateContactRequestManagerHandle( + walletId: walletId, + resolver: resolver, + shieldedAddress: shieldedAddress + ) + let requestHandle = request.handle + let establishedHandle: Handle = try await Task.detached(priority: .userInitiated) { + () -> Handle in + var outHandle: Handle = NULL_HANDLE + let resolverHandle = resolver.handle + try withExtendedLifetime((resolver, 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, + &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/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index f18b44a9fce..c7f1f5909f4 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -459,6 +459,38 @@ 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 request from 9fRYTJZW came privately; " + + "accept it with a private reply" + 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 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 From ef3a1a572808c0332c09788e6c04d31e32872b29 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 12:07:00 +0700 Subject: [PATCH 04/37] feat(swift-example-app): send contact requests privately and accept them privately - Add Contact has a "Send privately" toggle that sends the request from the wallet's shielded balance. - Accepting a request the public accept refuses as private replies privately instead, in the request list and in Add Contact's collision alert. Rust decides which; the app only reacts to the refusal. - Pull-to-refresh on the request list also reads the private store, which is what records that a request came privately. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 45 +++++++++++++++---- .../Views/DashPay/ContactRequestsView.swift | 10 ++++- .../Views/DashPay/ContactsView.swift | 30 +++++++++++++ 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 7a0f020ff9b..285c0e00123 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -50,6 +50,10 @@ 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? @@ -114,6 +118,7 @@ struct AddContactView: View { if let recipient = resolvedRecipient { previewSection(recipient: recipient) accountLabelSection + sendPrivatelySection sendSection } @@ -357,6 +362,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 { @@ -451,14 +465,24 @@ 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 - ) + if sendPrivately { + _ = try await walletManager.sendPrivateContactRequest( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + senderIdentityId: identity.identityId, + recipientIdentityId: recipient, + accountLabel: label.isEmpty ? nil : label + ) + } else { + let signer = KeychainSigner(modelContainer: modelContext.container) + _ = 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() @@ -486,7 +510,12 @@ struct AddContactView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - _ = try await wallet.acceptContactRequest(request, signer: signer) + try await acceptIncomingContactRequest( + request, + wallet: wallet, + walletManager: walletManager, + signer: signer + ) kickDashPaySync(walletManager) dismiss() } catch { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 0ad941084cb..99ba9062db5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -165,6 +165,9 @@ struct ContactRequestsView: View { } .refreshable { await attachOrStartSync(walletManager) + if let wallet = try? requireWallet() { + await syncPrivateContactRequests(wallet) + } } .onChange(of: rowSignature) { _, _ in pruneOverlays() @@ -225,7 +228,12 @@ struct ContactRequestsView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - _ = try await wallet.acceptContactRequest(request, signer: signer) + try await acceptIncomingContactRequest( + request, + wallet: wallet, + walletManager: walletManager, + signer: signer + ) // Optimistic removal — the persister will promote the // pair to established shortly. removedOverlayIds.insert(contactId) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index edc73820e76..538b185138a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -226,6 +226,36 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { Task { _ = try? await walletManager.dashPaySyncNow() } } +/// Read DashPay's private contact request store (protocol version 15) for +/// `wallet`. Best effort: a network before protocol version 15 has no such +/// store. Which requests came privately is kept in memory only, so this runs +/// with every pull-to-refresh of the request list. +@MainActor +func syncPrivateContactRequests(_ wallet: ManagedPlatformWallet) async { + _ = try? await wallet.syncPrivateContactRequests() +} + +/// Accept `request`. Rust refuses a public reply to a request that came +/// privately, as it would publish the relationship; that one is accepted with +/// a private reply instead, paid from the wallet's shielded balance. +@MainActor +func acceptIncomingContactRequest( + _ request: ContactRequest, + wallet: ManagedPlatformWallet, + walletManager: PlatformWalletManager, + signer: KeychainSigner +) async throws { + do { + _ = try await wallet.acceptContactRequest(request, signer: signer) + } catch PlatformWalletError.privateContactRequestNeedsPrivateReply { + _ = try await walletManager.acceptPrivateContactRequest( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + request: request + ) + } +} + // MARK: - Row model + view /// UI model for one established contact row, resolved from the From 29adfcae9ca653089acfd46a97b10b82411b3d57 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 17:58:47 +0700 Subject: [PATCH 05/37] fix(rs-sdk-ffi): seal private contact requests with the identity signer's signature Private contact requests are now signed by the sender's identity signer with one of its authentication keys, as public requests are. The resolver signer keeps only the DashPay encryption key: - `seal_private_contact_request` takes the signing key id and the 65-byte signature and passes them to the SDK seal. It signs nothing itself; the SDK refuses a signature that is not the signing key's. The encryption key, derived for the outgoing viewing key and the key check, is wiped on drop as before. - `read_private_contact_requests` is removed: checking an entry needs no secret key, so the wallet reads the scan itself. - `scan_private_document_page` takes each sender encryption key's id with its path, as `PrivateDocumentScanKeys::with_sender` now does. Tests sign with `SingleKeySigner` through `private_contact_request_signature`, as an identity signer signs data. The sealed entry is still byte for byte the one a resident wallet seals; a scanned page reads as the sender's signed request; a scan and a seal each resolve the mnemonic once; a signature by another key and a wrong encryption key path are refused, each for its own reason. Co-Authored-By: Claude Opus 5.5 --- .../src/mnemonic_resolver_core_signer.rs | 421 ++++++++++++------ 1 file changed, 294 insertions(+), 127 deletions(-) diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index 5780948b9fa..f27eeb698ce 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -56,7 +56,6 @@ //! Combined, no private key bytes survive past the trait-method //! boundary. -use std::collections::BTreeMap; use std::ffi::c_void; use std::os::raw::c_char; @@ -66,11 +65,9 @@ use dash_sdk::dpp::identity::{Identity, KeyID}; use dash_sdk::dpp::platform_value::BinaryData; use dash_sdk::dpp::prelude::Identifier; use dash_sdk::dpp::secp256k1; -use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::dashpay::{ - private_contact_request_outgoing_viewing_key, private_contact_requests_from_scan, - seal_private_contact_request, PrivateContactRequest, PrivateContactRequestScan, - PrivateContactRequestSender, + private_contact_request_outgoing_viewing_key, seal_private_contact_request, + PrivateContactRequest, PrivateContactRequestSender, }; use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; use dash_sdk::platform::private_document_scan::{PrivateDocumentScan, PrivateDocumentScanKeys}; @@ -181,9 +178,10 @@ pub enum MnemonicResolverSignerError { #[error("ECDSA signature conversion failed: {0}")] SignatureConversion(#[source] EcdsaError), - /// The SDK refused to read or seal a private contact request with the - /// derived keys: a malformed request, a key the identity does not hold, - /// or a contract without the private type. + /// The SDK refused to seal a private contact request with the derived + /// key: a malformed request, a key the identity does not hold, a + /// signature that is not the signing key's, or a contract without the + /// private type. #[error("private contact request: {0}")] PrivateContactRequest(dash_sdk::Error), } @@ -623,13 +621,13 @@ impl MnemonicResolverCoreSigner { /// wallet's keys ([`PrivateDocumentScanKeys::scan_page`]). `recipients` /// are the identities entries may be sealed to, each with the paths of /// its keys by key id; `senders` the identities that may have sealed - /// entries, each with the path of an encryption key whose outgoing - /// viewing key recovers what it sent. Every key comes from one resolve - /// and is dropped here; only the sorted entries return. + /// entries, each with the id and path of an encryption key whose + /// outgoing viewing key recovers what it sent. Every key comes from one + /// resolve and is dropped here; only the sorted entries return. pub fn scan_private_document_page( &self, recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], - senders: &[(Identifier, DerivationPath)], + senders: &[(Identifier, KeyID, DerivationPath)], data_contract_id: Identifier, document_type_name: &str, start_position: u64, @@ -645,39 +643,15 @@ impl MnemonicResolverCoreSigner { }) } - /// Read the private contact requests of `scan`, a scan of DashPay's - /// store made with the keys at `recipients` - /// ([`private_contact_requests_from_scan`]): each received request's - /// sender is checked with the recipient key that opened it, against the - /// key `sender_identities` holds under the id it names. Only the - /// recipient keys are derived, from one resolve, and dropped here. - pub fn read_private_contact_requests( - &self, - recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], - dashpay_contract: &DataContract, - scan: PrivateDocumentScan, - sender_identities: &BTreeMap>, - platform_version: &PlatformVersion, - ) -> Result { - self.resolve_master(|master| { - let keys = private_document_scan_keys(master, recipients, &[])?; - private_contact_requests_from_scan( - dashpay_contract, - scan, - &keys, - sender_identities, - platform_version, - ) - .map_err(MnemonicResolverSignerError::PrivateContactRequest) - }) - } - /// Seal `request` from `sender`, whose encryption key /// `sender_encryption_key_id` is the one at `sender_encryption_key_path`, - /// to the key `recipient_key_id` of `recipient` - /// ([`seal_private_contact_request`]). `esk_seed` is 32 fresh random - /// bytes, never reused. The encryption key, and the outgoing viewing key - /// the SDK derives from it, stay here; only the sealed entry returns. + /// to the key `recipient_key_id` of `recipient`, with `signature`, the + /// signature of the request by the sender's key `signing_key_id` + /// ([`seal_private_contact_request`]). The signature comes from the + /// identity signer, as a transition's does; this signs nothing. + /// `esk_seed` is 32 fresh random bytes, never reused. The encryption key, + /// and the outgoing viewing key the SDK derives from it, stay here; only + /// the sealed entry returns. #[allow(clippy::too_many_arguments)] pub fn seal_private_contact_request( &self, @@ -685,6 +659,8 @@ impl MnemonicResolverCoreSigner { dashpay_contract: &DataContract, sender: &Identity, sender_encryption_key_id: KeyID, + signing_key_id: KeyID, + signature: &[u8; 65], recipient: &Identity, recipient_key_id: KeyID, request: &PrivateContactRequest, @@ -698,6 +674,8 @@ impl MnemonicResolverCoreSigner { identity: sender, encryption_key_id: sender_encryption_key_id, encryption_secret_key: &secret.0, + signing_key_id, + signature, }, recipient, recipient_key_id, @@ -723,13 +701,13 @@ fn secret_key_at( } /// The scan keys of `recipients` and `senders`, derived below `master`: each -/// recipient's secret keys, and each sender's outgoing viewing key -/// ([`private_contact_request_outgoing_viewing_key`]). The copies made here -/// are wiped before this returns. +/// recipient's secret keys, and the outgoing viewing key of each sender +/// encryption key ([`private_contact_request_outgoing_viewing_key`]). The +/// copies made here are wiped before this returns. fn private_document_scan_keys( master: &ExtendedPrivKey, recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], - senders: &[(Identifier, DerivationPath)], + senders: &[(Identifier, KeyID, DerivationPath)], ) -> Result { let mut keys = PrivateDocumentScanKeys::new(); for (identity_id, paths) in recipients { @@ -741,11 +719,11 @@ fn private_document_scan_keys( } keys = keys.with_recipient(*identity_id, &secret_keys.0); } - for (identity_id, path) in senders { + for (identity_id, key_id, path) in senders { let secret = secret_key_at(master, path)?; let outgoing_viewing_key = Zeroizing::new(private_contact_request_outgoing_viewing_key(&secret.0)); - keys = keys.with_sender(*identity_id, *outgoing_viewing_key); + keys = keys.with_sender(*identity_id, *key_id, *outgoing_viewing_key); } Ok(keys) } @@ -1407,16 +1385,24 @@ mod tests { use super::*; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::{ IdentityPublicKey, IdentityV0, KeyType, Purpose, SecurityLevel, }; use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; - use dash_sdk::platform::dashpay::PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::dashpay::{ + private_contact_request_identities, private_contact_request_signature, + private_contact_request_signing_data, private_contact_requests_from_scan, + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }; use dash_sdk::platform::private_document_entries::PrivateDocumentEntry; use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; + use simple_signer::SingleKeySigner; + use std::collections::BTreeMap; use std::sync::atomic::{AtomicUsize, Ordering}; const SENDER_ID: [u8; 32] = [0xA0; 32]; @@ -1424,6 +1410,8 @@ mod tests { /// Two identities of another wallet, whose entry the scan passes over. const OUTSIDER_ID: [u8; 32] = [0xC0; 32]; const CAROL_ID: [u8; 32] = [0xD0; 32]; + /// The authentication key a sender signs its requests with. + const SIGNING_KEY_ID: KeyID = 0; const SENDER_KEY_ID: KeyID = 1; const RECIPIENT_KEY_ID: KeyID = 2; const BLOCK_TIME_MS: u64 = 1_700_000_000_000; @@ -1456,19 +1444,34 @@ mod tests { .expect("a valid scalar") } + fn fixed_secret_key(byte: u8) -> secp256k1::SecretKey { + secp256k1::SecretKey::from_secret_bytes([byte; 32]).expect("a valid scalar") + } + + /// The sender's signing key, which the identity signer holds, not the + /// resolver. + fn sender_signing_secret_key() -> secp256k1::SecretKey { + fixed_secret_key(0x51) + } + fn outsider_secret_key() -> secp256k1::SecretKey { - secp256k1::SecretKey::from_secret_bytes([0x41; 32]).expect("a valid scalar") + fixed_secret_key(0x41) + } + + fn outsider_signing_secret_key() -> secp256k1::SecretKey { + fixed_secret_key(0x52) } fn key( id: KeyID, purpose: Purpose, + security_level: SecurityLevel, secret_key: &secp256k1::SecretKey, ) -> IdentityPublicKey { IdentityPublicKey::V0(IdentityPublicKeyV0 { id, purpose, - security_level: SecurityLevel::MEDIUM, + security_level, contract_bounds: None, key_type: KeyType::ECDSA_SECP256K1, read_only: false, @@ -1481,16 +1484,20 @@ mod tests { }) } - fn identity( - id: [u8; 32], - key_id: KeyID, - purpose: Purpose, - secret_key: &secp256k1::SecretKey, - ) -> Identity { - let key = key(key_id, purpose, secret_key); + /// A `HIGH` authentication key, which may sign DashPay's documents. + fn signing_key(secret_key: &secp256k1::SecretKey) -> IdentityPublicKey { + key( + SIGNING_KEY_ID, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + secret_key, + ) + } + + fn identity(id: [u8; 32], keys: Vec) -> Identity { Identity::V0(IdentityV0 { id: Identifier::from(id), - public_keys: BTreeMap::from([(key_id, key)]), + public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), balance: 0, revision: 0, }) @@ -1499,36 +1506,54 @@ mod tests { fn sender() -> Identity { identity( SENDER_ID, - SENDER_KEY_ID, - Purpose::ENCRYPTION, - &seed_secret_key(&sender_key_path()), + vec![ + signing_key(&sender_signing_secret_key()), + key( + SENDER_KEY_ID, + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, + &seed_secret_key(&sender_key_path()), + ), + ], ) } fn recipient() -> Identity { identity( RECIPIENT_ID, - RECIPIENT_KEY_ID, - Purpose::DECRYPTION, - &seed_secret_key(&recipient_key_path()), + vec![key( + RECIPIENT_KEY_ID, + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + &seed_secret_key(&recipient_key_path()), + )], ) } fn outsider() -> Identity { identity( OUTSIDER_ID, - SENDER_KEY_ID, - Purpose::ENCRYPTION, - &outsider_secret_key(), + vec![ + signing_key(&outsider_signing_secret_key()), + key( + SENDER_KEY_ID, + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, + &outsider_secret_key(), + ), + ], ) } fn carol() -> Identity { identity( CAROL_ID, - RECIPIENT_KEY_ID, - Purpose::DECRYPTION, - &secp256k1::SecretKey::from_secret_bytes([0x42; 32]).expect("a valid scalar"), + vec![key( + RECIPIENT_KEY_ID, + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + &fixed_secret_key(0x42), + )], ) } @@ -1554,12 +1579,47 @@ mod tests { } } - /// `request` sealed by `sender` with `secret_key`, the key a resident - /// wallet holds, to the decryption key of `recipient`. + /// The signature of `request()` from `sender`'s encryption key to the + /// decryption key of `recipient`, as the wallet gets it: an identity + /// signer holding `signing_secret_key` signs the request's signing + /// data with `sender`'s signing key, as it signs a transition. + async fn signature( + contract: &DataContract, + sender: &Identity, + recipient: &Identity, + signing_secret_key: &secp256k1::SecretKey, + ) -> [u8; 65] { + let signing_key = sender + .get_public_key_by_id(SIGNING_KEY_ID) + .expect("the sender has a signing key") + .clone(); + let signer = SingleKeySigner::new_from_slice( + &signing_secret_key.to_secret_bytes(), + Network::Testnet, + ) + .expect("a valid signing key"); + let signing_data = private_contact_request_signing_data( + contract, + sender, + SENDER_KEY_ID, + recipient, + RECIPIENT_KEY_ID, + &request(), + ) + .expect("the keys check"); + private_contact_request_signature(&signer, &signing_key, &signing_data) + .await + .expect("the identity signer signs") + } + + /// `request` sealed by `sender` with `secret_key`, the encryption key + /// a resident wallet holds, and `signature`, to the decryption key of + /// `recipient`. fn seed_sealed( contract: &DataContract, sender: &Identity, secret_key: &secp256k1::SecretKey, + signature: &[u8; 65], recipient: &Identity, esk_seed: u8, ) -> BinaryData { @@ -1569,6 +1629,8 @@ mod tests { identity: sender, encryption_key_id: SENDER_KEY_ID, encryption_secret_key: secret_key, + signing_key_id: SIGNING_KEY_ID, + signature, }, recipient, RECIPIENT_KEY_ID, @@ -1579,16 +1641,43 @@ mod tests { } /// The test entry: the sender's request to the recipient. - fn sent_to_recipient(contract: &DataContract, esk_seed: u8) -> BinaryData { + async fn sent_to_recipient(contract: &DataContract, esk_seed: u8) -> BinaryData { + let signature = signature( + contract, + &sender(), + &recipient(), + &sender_signing_secret_key(), + ) + .await; seed_sealed( contract, &sender(), &seed_secret_key(&sender_key_path()), + &signature, &recipient(), esk_seed, ) } + /// Another wallet's entry: the outsider's request to carol. + async fn sent_to_carol(contract: &DataContract, esk_seed: u8) -> BinaryData { + let signature = signature( + contract, + &outsider(), + &carol(), + &outsider_signing_secret_key(), + ) + .await; + seed_sealed( + contract, + &outsider(), + &outsider_secret_key(), + &signature, + &carol(), + esk_seed, + ) + } + fn page(entries: Vec) -> PrivateDocumentEntries { PrivateDocumentEntries { total_count: entries.len() as u64, @@ -1614,8 +1703,12 @@ mod tests { } /// The wallet's sender key paths: the sender's encryption key. - fn sender_paths() -> Vec<(Identifier, DerivationPath)> { - vec![(Identifier::from(SENDER_ID), sender_key_path())] + fn sender_paths() -> Vec<(Identifier, KeyID, DerivationPath)> { + vec![( + Identifier::from(SENDER_ID), + SENDER_KEY_ID, + sender_key_path(), + )] } /// The scan keys a resident wallet of the same seed builds. @@ -1627,6 +1720,7 @@ mod tests { ) .with_sender( Identifier::from(SENDER_ID), + SENDER_KEY_ID, private_contact_request_outgoing_viewing_key(&seed_secret_key( &sender_key_path(), )), @@ -1644,12 +1738,20 @@ mod tests { english_resolve(ctx, wallet_id_bytes, out_buf, out_capacity, out_len) } - /// The entry the signer seals is byte for byte the one a resident - /// wallet seals with the key at the same path, so a request sent - /// through the Keychain opens and authenticates for every client. - #[test] - fn should_seal_the_entry_a_seed_derived_sender_key_seals() { + /// The entry the signer seals with the identity signer's signature is + /// byte for byte the one a resident wallet seals with the encryption + /// key at the same path, so a request sent through the Keychain opens + /// and authenticates for every client. + #[tokio::test] + async fn should_seal_the_entry_a_seed_derived_sender_key_seals() { let contract = dashpay(); + let signature = signature( + &contract, + &sender(), + &recipient(), + &sender_signing_secret_key(), + ) + .await; let resolver = make_resolver(english_resolve); let signer = unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; @@ -1660,29 +1762,31 @@ mod tests { &contract, &sender(), SENDER_KEY_ID, + SIGNING_KEY_ID, + &signature, &recipient(), RECIPIENT_KEY_ID, &request(), &[7; 32], ) .expect("the signer seals the request"); - assert_eq!(sealed, sent_to_recipient(&contract, 7)); + assert_eq!(sealed, sent_to_recipient(&contract, 7).await); unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } - /// A page scanned and read through the signer sorts and reads as with - /// a resident wallet's scan keys: the request to the recipient, with - /// its sender checked, and the same request recovered as sent by the - /// sender's outgoing viewing key, while another wallet's entry is - /// passed over. - #[test] - fn should_scan_and_read_a_page_as_seed_derived_scan_keys_do() { + /// A page scanned through the signer sorts as with a resident wallet's + /// scan keys: the request to the recipient, and the same request + /// recovered as sent by the sender's outgoing viewing key, while + /// another wallet's entry is passed over. Both read as the sender's + /// signed request, which needs no secret key. + #[tokio::test] + async fn should_scan_a_page_as_seed_derived_scan_keys_do() { let contract = dashpay(); let target = target(&contract); let page = page(vec![ - seed_sealed(&contract, &outsider(), &outsider_secret_key(), &carol(), 1), - sent_to_recipient(&contract, 2), + sent_to_carol(&contract, 1).await, + sent_to_recipient(&contract, 2).await, ]); let resolver = make_resolver(english_resolve); let signer = @@ -1698,51 +1802,54 @@ mod tests { &page, ) .expect("the signer scans the page"); - let seed_keys = seed_scan_keys(); - let expected_scan = seed_keys.scan_page(&target, 0, &page); - assert_eq!(scan, expected_scan); + assert_eq!(scan, seed_scan_keys().scan_page(&target, 0, &page)); assert_eq!(scan.received.len(), 1, "the request to the recipient"); assert_eq!(scan.sent.len(), 1, "the request the sender sent"); assert!(scan.rejected.is_empty()); assert!(scan.is_complete()); - let sender_identities = BTreeMap::from([(Identifier::from(SENDER_ID), Some(sender()))]); - let read = signer - .read_private_contact_requests( - &recipient_paths(), - &contract, - scan, - &sender_identities, - PlatformVersion::latest(), - ) - .expect("the signer reads the requests"); - let expected_read = private_contact_requests_from_scan( + let identities: BTreeMap<_, _> = [sender(), recipient()] + .into_iter() + .map(|identity| (identity.id(), Some(identity))) + .collect(); + assert_eq!( + private_contact_request_identities(&scan), + identities.keys().copied().collect(), + "the read checks the sender and the recipient" + ); + let read = private_contact_requests_from_scan( &contract, - expected_scan, - &seed_keys, - &sender_identities, + scan, + &identities, PlatformVersion::latest(), ) - .expect("the resident wallet reads the requests"); - assert_eq!(read, expected_read); + .expect("the requests read"); + assert!(read.rejected.is_empty(), "rejected: {:?}", read.rejected); assert_eq!(read.received.len(), 1); assert_eq!(read.received[0].sender_id, sender().id()); assert_eq!(read.received[0].sender_key_id, SENDER_KEY_ID); assert_eq!(read.received[0].recipient_key_id, RECIPIENT_KEY_ID); assert_eq!(read.received[0].request, request()); assert_eq!(read.sent.len(), 1); + assert_eq!(read.sent[0].sender_key_id, SENDER_KEY_ID); assert_eq!(read.sent[0].recipient_id, recipient().id()); - assert!(read.rejected.is_empty()); unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } - /// Every key a scan or a read needs comes from one Keychain read, not - /// one per key. - #[test] - fn should_resolve_the_mnemonic_once_per_scan_and_once_per_read() { + /// Every key a scan needs comes from one Keychain read, not one per + /// key, and a seal reads the Keychain once. + #[tokio::test] + async fn should_resolve_the_mnemonic_once_per_scan_and_once_per_seal() { let contract = dashpay(); - let page = page(vec![sent_to_recipient(&contract, 3)]); + let page = page(vec![sent_to_recipient(&contract, 3).await]); + let signature = signature( + &contract, + &sender(), + &recipient(), + &sender_signing_secret_key(), + ) + .await; let resolves = AtomicUsize::new(0); let resolver = unsafe { dash_sdk_mnemonic_resolver_create( @@ -1765,7 +1872,7 @@ mod tests { ], )]; - let scan = signer + signer .scan_private_document_page( &recipients, &sender_paths(), @@ -1777,16 +1884,20 @@ mod tests { .expect("the signer scans the page"); assert_eq!(resolves.load(Ordering::SeqCst), 1); - let sender_identities = BTreeMap::from([(Identifier::from(SENDER_ID), Some(sender()))]); signer - .read_private_contact_requests( - &recipients, + .seal_private_contact_request( + &sender_key_path(), &contract, - scan, - &sender_identities, - PlatformVersion::latest(), + &sender(), + SENDER_KEY_ID, + SIGNING_KEY_ID, + &signature, + &recipient(), + RECIPIENT_KEY_ID, + &request(), + &[4; 32], ) - .expect("the signer reads the requests"); + .expect("the signer seals the request"); assert_eq!(resolves.load(Ordering::SeqCst), 2); unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; @@ -1794,9 +1905,16 @@ mod tests { /// A path that is not the sender's encryption key derives a key the /// SDK refuses to seal with, and the refusal reaches the caller typed. - #[test] - fn should_refuse_to_seal_with_a_key_the_sender_does_not_hold() { + #[tokio::test] + async fn should_refuse_to_seal_with_a_key_the_sender_does_not_hold() { let contract = dashpay(); + let signature = signature( + &contract, + &sender(), + &recipient(), + &sender_signing_secret_key(), + ) + .await; let resolver = make_resolver(english_resolve); let signer = unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; @@ -1807,6 +1925,8 @@ mod tests { &contract, &sender(), SENDER_KEY_ID, + SIGNING_KEY_ID, + &signature, &recipient(), RECIPIENT_KEY_ID, &request(), @@ -1817,6 +1937,53 @@ mod tests { matches!(err, MnemonicResolverSignerError::PrivateContactRequest(_)), "expected the SDK's refusal, got: {err:?}" ); + assert!( + err.to_string().contains("secret key"), + "refused for the encryption key, got: {err}" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// The signer seals only what the identity signer signed: a + /// signature by a key other than the signing key it names is the + /// SDK's refusal, not an entry. + #[tokio::test] + async fn should_refuse_to_seal_a_signature_by_another_key() { + let contract = dashpay(); + let forged = signature( + &contract, + &sender(), + &recipient(), + &outsider_signing_secret_key(), + ) + .await; + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let err = signer + .seal_private_contact_request( + &sender_key_path(), + &contract, + &sender(), + SENDER_KEY_ID, + SIGNING_KEY_ID, + &forged, + &recipient(), + RECIPIENT_KEY_ID, + &request(), + &[7; 32], + ) + .expect_err("the signature is not the signing key's"); + assert!( + matches!(err, MnemonicResolverSignerError::PrivateContactRequest(_)), + "expected the SDK's refusal, got: {err:?}" + ); + assert!( + err.to_string().contains("signature"), + "refused for the signature, got: {err}" + ); unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } From 05e2bdeb64290219a81e4c1a4df007e796ea0924 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 17:58:47 +0700 Subject: [PATCH 06/37] fix(platform-wallet): take the identity signer in the private contact request FFI The private send and accept now take the identity signer the public send and accept take, and pass it to the wallet, which signs the request with it: platform_wallet_manager_send_private_contact_request(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) platform_wallet_manager_accept_private_contact_request(handle, wallet_id_bytes, mnemonic_resolver_handle, account, request_handle, shielded_address, signer_handle, out_established_handle) `signer_handle` is a `*mut SignerHandle` (a `VTableSigner`), crossed into the worker as an address and re-materialized there, as `platform_wallet_send_contact_request_with_signer` does. A null signer is `ErrorNullPointer` before the wallet is touched. `ResolverContactCryptoProvider` follows the new provider trait: no `read_private_contact_requests`, and the seal passes the signing key id and signature through. `managed_identity_get_private_contact` maps the owned `PrivateContact`; its docs say the flags now hold across restarts and only the shielded address waits for a sync. Code 68 (`ErrorPrivateContactRequestNeedsPrivateReply`) also covers a public send to a contact whose requests came or went privately; its docs and the registry row say so. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- .../rs-platform-wallet-ffi/src/dashpay.rs | 27 +--- packages/rs-platform-wallet-ffi/src/error.rs | 17 ++- .../src/private_contact_request.rs | 130 +++++++++++++++--- 4 files changed, 126 insertions(+), 50 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 1909407eccf..1d9f095a17b 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -181,7 +181,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding | | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — an asset lock needs more inputs than one may carry (100 on Dash Core relay policy and Platform consensus): the full drain, or since #5712 a fixed amount held only in many small coins (identity registration, top-up, invitation, platform-address funding). Two meanings. On a build: nothing was broadcast, the reservation is released, no partial drain, and a rebuild after consolidating is safe. On the resume of a tracked `Built` / `Broadcast` lock over the cap (since #5714): it is not re-sent, but an earlier attempt may have reached peers; the row stays tracked and nothing is released. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | -| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The public accept refused a contact request that came privately (protocol version 15): a `contactRequest` document in reply would publish the relationship. Nothing was sent; the host accepts it with `platform_wallet_manager_accept_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer | +| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the host accepts with `platform_wallet_manager_accept_private_contact_request` or sends with `platform_wallet_manager_send_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer | **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/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index b7ed4f9fb4a..0dbe5026682 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -32,17 +32,15 @@ //! established contact via their `DashpayExternalAccount`. Runs //! the Core transaction path internally. -use std::collections::BTreeMap; use std::ffi::CStr; use std::os::raw::c_char; -use dash_sdk::platform::dashpay::{PrivateContactRequest, PrivateContactRequestScan}; +use dash_sdk::platform::dashpay::PrivateContactRequest; use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; use dash_sdk::platform::private_document_scan::PrivateDocumentScan; use dpp::identity::{Identity, KeyID}; use dpp::platform_value::BinaryData; use dpp::prelude::{DataContract, Identifier}; -use dpp::version::PlatformVersion; use platform_wallet::{ContactRequest, PlatformWalletError, PrivateDocumentKeyPaths}; use rs_sdk_ffi::{ MnemonicResolverCoreSigner, MnemonicResolverHandle, MnemonicResolverSignerError, SignerHandle, @@ -1266,31 +1264,14 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { .map_err(private_document_signer_error) } - async fn read_private_contact_requests( - &self, - keys: &PrivateDocumentKeyPaths, - dashpay_contract: &DataContract, - scan: PrivateDocumentScan, - sender_identities: &BTreeMap>, - platform_version: &PlatformVersion, - ) -> Result { - self.signer - .read_private_contact_requests( - &keys.recipients, - dashpay_contract, - scan, - sender_identities, - platform_version, - ) - .map_err(private_document_signer_error) - } - async fn seal_private_contact_request( &self, sender_encryption_key_path: &key_wallet::bip32::DerivationPath, dashpay_contract: &DataContract, sender: &Identity, sender_encryption_key_id: KeyID, + signing_key_id: KeyID, + signature: &[u8; 65], recipient: &Identity, recipient_key_id: KeyID, request: &PrivateContactRequest, @@ -1302,6 +1283,8 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { dashpay_contract, sender, sender_encryption_key_id, + signing_key_id, + signature, recipient, recipient_key_id, request, diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 75958a5441a..5085f8047c3 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -623,11 +623,13 @@ pub enum PlatformWalletFFIResultCode { ErrorAssetLockTooManyInputs = 60, /// Maps `PlatformWalletError::PrivateContactRequestNeedsPrivateReply`: - /// the contact request came privately (protocol version 15), so a public - /// `contactRequest` reply would publish the relationship. Nothing was - /// sent. Accept it with - /// `platform_wallet_manager_accept_private_contact_request` instead. - /// 61 to 67 are claimed by open branches, so this takes 68. + /// the contact requests with that identity came or went privately + /// (protocol version 15), so a public `contactRequest` to it, in reply or + /// as a rotation, would publish the relationship. The public send and the + /// public accept refuse it, and nothing was sent. Send it with + /// `platform_wallet_manager_send_private_contact_request`, or accept it + /// with `platform_wallet_manager_accept_private_contact_request`, + /// instead. 61 to 67 are claimed by open branches, so this takes 68. ErrorPrivateContactRequestNeedsPrivateReply = 68, /// The named thing does not exist. @@ -1067,8 +1069,9 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::SpvProcessRestartRequired(..) => { PlatformWalletFFIResultCode::ErrorSpvProcessRestartRequired } - // The public accept refused a request that came privately; the - // host routes it to the private accept. + // The public send or accept refused a contact whose requests came + // or went privately; the host routes it to the private send or + // accept. PlatformWalletError::PrivateContactRequestNeedsPrivateReply(..) => { PlatformWalletFFIResultCode::ErrorPrivateContactRequestNeedsPrivateReply } diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index 3adb8a69ca0..a8eea803720 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -8,22 +8,27 @@ //! 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. Every key stays in -//! the resolver-backed signer. +//! 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. -//! Built with the `shielded` feature only. +//! 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. //! - [`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. //! -//! A request that came privately is refused by the public accept with -//! `ErrorPrivateContactRequestNeedsPrivateReply`; the host routes it to the -//! private accept. +//! The public send and accept refuse a contact whose requests came or went +//! privately with `ErrorPrivateContactRequestNeedsPrivateReply`; the host +//! routes the request to the private send or accept. use platform_wallet::PrivateContact; use rs_sdk_ffi::MnemonicResolverHandle; +#[cfg(feature = "shielded")] +use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::dashpay::{resolver_contact_crypto_provider, ContactRequestHandleArray}; use crate::error::*; @@ -90,9 +95,9 @@ fn saturating_count(count: usize) -> u32 { /// their DashPay accounts are queued as the document sync does. /// /// `core_signer_handle` is the wallet-HD resolver signer: the store is opened -/// and senders are checked with keys derived inside it. The cursors live in -/// memory only, so the first pass of a process reads the store from its -/// start. +/// 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 @@ -184,8 +189,8 @@ impl PrivateContactFFI { } } -impl From<&PrivateContact> for PrivateContactFFI { - fn from(contact: &PrivateContact) -> Self { +impl From for PrivateContactFFI { + fn from(contact: PrivateContact) -> Self { Self { received_privately: contact.received_privately, sent_privately: contact.sent_privately, @@ -198,9 +203,12 @@ impl From<&PrivateContact> for PrivateContactFFI { } /// Read what this identity's private contact requests with `contact_id` -/// carry into `out_contact`. Returns `NotFound` when no request between them -/// went privately, as far as this process has seen: the record is in memory -/// only and rebuilt by `platform_wallet_sync_private_contact_requests`. +/// 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. @@ -250,9 +258,11 @@ unsafe fn read_shielded_address(ptr: *const u8) -> Option<[u8; SHIELDED_ADDRESS_ /// key inside the resolver-backed signer. /// /// `mnemonic_resolver_handle` supplies both the transient Orchard spend -/// authority and the DashPay keys. Writes a handle into -/// `CONTACT_REQUEST_STORAGE` to `out_request_handle` (release with -/// `contact_request_destroy`). +/// 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. @@ -268,6 +278,9 @@ unsafe fn read_shielded_address(ptr: *const u8) -> Option<[u8; SHIELDED_ADDRESS_ /// `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] @@ -283,6 +296,7 @@ pub unsafe extern "C" fn platform_wallet_manager_send_private_contact_request( 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 @@ -300,6 +314,7 @@ pub unsafe extern "C" fn platform_wallet_manager_send_private_contact_request( auto_accept_proof, auto_accept_proof_len, shielded_address, + signer_handle, out_request_handle, ) }) @@ -323,10 +338,12 @@ unsafe fn send_private_contact_request_inner( 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 }; @@ -358,10 +375,16 @@ unsafe fn send_private_contact_request_inner( 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( @@ -373,6 +396,7 @@ unsafe fn send_private_contact_request_inner( label, AutoAcceptProofSource::from_option(proof), shielded_address, + signer, &crypto, &prover, ) @@ -396,7 +420,9 @@ unsafe fn send_private_contact_request_inner( /// privately; null omits it. /// /// `request_handle` is a live `CONTACT_REQUEST_STORAGE` handle of the -/// incoming request. Writes a handle into `ESTABLISHED_CONTACT_STORAGE` to +/// 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 @@ -404,9 +430,13 @@ unsafe fn send_private_contact_request_inner( /// - `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, @@ -414,6 +444,7 @@ pub unsafe extern "C" fn platform_wallet_manager_accept_private_contact_request( 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. @@ -425,6 +456,7 @@ pub unsafe extern "C" fn platform_wallet_manager_accept_private_contact_request( account, request_handle, shielded_address, + signer_handle, out_established_handle, ) }) @@ -436,6 +468,7 @@ pub unsafe extern "C" fn platform_wallet_manager_accept_private_contact_request( /// # 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, @@ -443,10 +476,12 @@ unsafe fn accept_private_contact_request_inner( 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 }; @@ -469,8 +504,11 @@ unsafe fn accept_private_contact_request_inner( 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( @@ -479,6 +517,7 @@ unsafe fn accept_private_contact_request_inner( account, &request, shielded_address, + signer, &crypto, &prover, ) @@ -546,10 +585,61 @@ mod tests { 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); + } + #[test] fn should_carry_every_field_of_a_private_contact() { let address = [0x55; SHIELDED_ADDRESS_LEN]; - let with_address = PrivateContactFFI::from(&PrivateContact { + let with_address = PrivateContactFFI::from(PrivateContact { received_privately: true, sent_privately: false, shielded_address: Some(address), @@ -559,7 +649,7 @@ mod tests { assert!(with_address.has_shielded_address); assert_eq!(with_address.shielded_address, address); - let without_address = PrivateContactFFI::from(&PrivateContact { + let without_address = PrivateContactFFI::from(PrivateContact { received_privately: false, sent_privately: true, shielded_address: None, From c83c19fb7a9d90461613b1740f92ccad45b2431b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 18:00:16 +0700 Subject: [PATCH 07/37] fix(swift-sdk): sign private contact requests with the identity signer `PlatformWalletManager.sendPrivateContactRequest` and `acceptPrivateContactRequest` take `signer: KeychainSigner`, last as in `sendContactRequest` and `acceptContactRequest`, and keep it alive across the FFI call with the resolver. The docs say the private request is signed by the same identity signer, that `privateContact(contactId:)` holds across launches (only the shielded address waits for a sync), and that the public send also throws `privateContactRequestNeedsPrivateReply` for a contact whose requests came or went privately. The `ErrorHandlingTests` pin of code 68 carries the wallet's new message. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/PlatformWalletResult.swift | 14 ++-- .../PrivateContactRequests.swift | 70 ++++++++++++------- .../ErrorHandlingTests.swift | 4 +- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 2c9abc26201..2ea5fa7e59c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -265,8 +265,9 @@ 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 request came privately, so a public reply would publish - /// the relationship. Nothing was sent; accept it privately. + /// 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 named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the @@ -592,9 +593,12 @@ 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 request came privately (protocol version 15): a public - /// reply would publish the relationship, so nothing was sent. Accept it - /// with `PlatformWalletManager.acceptPrivateContactRequest`. + /// 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.acceptPrivateContactRequest`, or send with + /// `PlatformWalletManager.sendPrivateContactRequest`. case privateContactRequestNeedsPrivateReply(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index d4337dc486f..13debee5447 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -5,17 +5,20 @@ import DashSDKFFI // // 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. -// The wallet records it as an ordinary `ContactRequest`; what that type has no -// field for (that it came privately, and the contact's shielded address) is -// read with `ManagedIdentity.privateContact(contactId:)`. +// 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:)`. // -// Routing an accept: the public `acceptContactRequest` throws -// `PlatformWalletError.privateContactRequestNeedsPrivateReply` for a request -// that came privately; accept it with -// `PlatformWalletManager.acceptPrivateContactRequest` instead. +// Routing: the public `acceptContactRequest` and `sendContactRequest` throw +// `PlatformWalletError.privateContactRequestNeedsPrivateReply` for a contact +// whose requests came or went privately; accept it with +// `PlatformWalletManager.acceptPrivateContactRequest`, or send with +// `PlatformWalletManager.sendPrivateContactRequest`, instead. -/// What an identity's private contact requests with one contact carry beyond -/// the `ContactRequest`. Rust keeps it in memory only and rebuilds it with +/// How an identity and one contact exchanged private contact requests. Rust +/// reads it from the stored requests, so it holds across launches; only the +/// contact's shielded address is kept in memory, rebuilt by /// `ManagedPlatformWallet.syncPrivateContactRequests`. public struct PrivateContactInfo: Sendable, Equatable { /// The contact's request came privately, so accepting it replies @@ -23,7 +26,9 @@ public struct PrivateContactInfo: Sendable, Equatable { 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. + /// 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) { @@ -56,9 +61,9 @@ public struct PrivateContactSyncResult: Sendable { } extension ManagedIdentity { - /// What this identity's private contact requests with `contactId` carry, - /// or `nil` when none between them went privately as far as this process - /// has seen. + /// 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 @@ -78,10 +83,12 @@ extension ManagedPlatformWallet { /// and ingest what it holds for them: requests to them, each sender /// checked, and requests they sent, recovered from the store alone. /// - /// The keys stay in the Keychain mnemonic resolver Rust-side. The store - /// cursors live in memory only, so the first pass after launch reads the - /// store from its start, and until it has run the public accept cannot - /// tell that a request came privately. + /// 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. The store cursors live in memory only, so 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 { @@ -130,8 +137,11 @@ extension PlatformWalletManager { /// 43-byte Orchard address the contact may pay us at privately. /// /// `resolver` supplies both the Orchard spend authority and the DashPay - /// keys. Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the - /// payment may already be on chain: do not send again. + /// keys. `signer` is the identity signer `sendContactRequest` takes: it + /// signs the request with one of the sender's authentication keys, as it + /// signs a public request's transition. Throws + /// `PlatformWalletError.shieldedSpendUnconfirmed` when the payment may + /// already be on chain: do not send again. public func sendPrivateContactRequest( walletId: Data, resolver: MnemonicResolver, @@ -140,20 +150,23 @@ extension PlatformWalletManager { recipientIdentityId: Identifier, accountLabel: String? = nil, autoAcceptProof: Data? = nil, - shieldedAddress: Data? = nil + shieldedAddress: Data? = nil, + signer: KeychainSigner ) async throws -> ContactRequest { let handle = try privateContactRequestManagerHandle( walletId: walletId, resolver: resolver, shieldedAddress: shieldedAddress ) + 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 resolver keepalive across the FFI call: same - // rationale as `shieldedTransfer`. - try withExtendedLifetime(resolver) { + // 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 @@ -171,6 +184,7 @@ extension PlatformWalletManager { proofPtr, proofLen, addressPtr, + signerHandle, &outHandle ).check() } @@ -188,13 +202,15 @@ extension PlatformWalletManager { /// 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. + /// us at privately. `signer` is the identity signer + /// `acceptContactRequest` takes; it signs the reply. public func acceptPrivateContactRequest( walletId: Data, resolver: MnemonicResolver, account: UInt32 = 0, request: ContactRequest, - shieldedAddress: Data? = nil + shieldedAddress: Data? = nil, + signer: KeychainSigner ) async throws -> EstablishedContact { let handle = try privateContactRequestManagerHandle( walletId: walletId, @@ -202,11 +218,12 @@ extension PlatformWalletManager { shieldedAddress: shieldedAddress ) 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, request)) { + try withExtendedLifetime((resolver, signer, request)) { try walletId.withFFIBytes { widPtr in try withOptionalBytes(shieldedAddress) { addressPtr, _ in try platform_wallet_manager_accept_private_contact_request( @@ -216,6 +233,7 @@ extension PlatformWalletManager { account, requestHandle, addressPtr, + signerHandle, &outHandle ).check() } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index c7f1f5909f4..0cedd0ba809 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -465,8 +465,8 @@ final class ErrorHandlingTests: XCTestCase { ) XCTAssertEqual(code, .errorPrivateContactRequestNeedsPrivateReply) XCTAssertEqual(code.rawValue, 68) - let detail = "the contact request from 9fRYTJZW came privately; " - + "accept it with a private reply" + 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") From ff3791cf79c5fd1e6ebd1b8cf93eec84fdd1e2a5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 18:00:16 +0700 Subject: [PATCH 08/37] fix(swift-example-app): sign private contact requests with the Keychain signer Add Contact's private send and the private-accept fallback pass the `KeychainSigner` the public send and accept already use. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 6 ++++-- .../Views/DashPay/ContactsView.swift | 14 ++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 285c0e00123..6576a9006e1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -466,16 +466,18 @@ struct AddContactView: View { do { let wallet = try requireWallet() let label = accountLabel.trimmingCharacters(in: .whitespacesAndNewlines) + // 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 + accountLabel: label.isEmpty ? nil : label, + signer: signer ) } else { - let signer = KeychainSigner(modelContainer: modelContext.container) _ = try await wallet.sendContactRequest( senderIdentityId: identity.identityId, recipientIdentityId: recipient, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index 538b185138a..4083f1e03a9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -228,16 +228,17 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { /// Read DashPay's private contact request store (protocol version 15) for /// `wallet`. Best effort: a network before protocol version 15 has no such -/// store. Which requests came privately is kept in memory only, so this runs -/// with every pull-to-refresh of the request list. +/// store. Private requests reach the wallet only through this read, so it +/// runs with every pull-to-refresh of the request list. @MainActor func syncPrivateContactRequests(_ wallet: ManagedPlatformWallet) async { _ = try? await wallet.syncPrivateContactRequests() } -/// Accept `request`. Rust refuses a public reply to a request that came -/// privately, as it would publish the relationship; that one is accepted with -/// a private reply instead, paid from the wallet's shielded balance. +/// Accept `request`. Rust refuses a public reply to a contact whose requests +/// came privately, as it would publish the relationship; that one is accepted +/// with a private reply instead, paid from the wallet's shielded balance and +/// signed by the same `signer`. @MainActor func acceptIncomingContactRequest( _ request: ContactRequest, @@ -251,7 +252,8 @@ func acceptIncomingContactRequest( _ = try await walletManager.acceptPrivateContactRequest( walletId: wallet.walletId, resolver: MnemonicResolver(), - request: request + request: request, + signer: signer ) } } From bd66fb9cf52d75eab8e366ab43402d4cdbff4839 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 20:32:30 +0700 Subject: [PATCH 09/37] feat(platform-wallet): accept a contact request replying in kind over the FFI `platform_wallet_manager_accept_contact_request_replying_in_kind(handle, wallet_id_bytes, mnemonic_resolver_handle, account, request_handle, shielded_address, signer_handle, out_established_handle)` (`shielded` feature) exposes `PlatformWallet::accept_contact_request_replying_in_kind`: the one accept a host calls. The wallet replies privately to a request that came privately with no reply yet, and with a document (or by adopting the reply already sent) otherwise, so hosts no longer catch code 68 and call the private accept themselves. It is built as `platform_wallet_manager_accept_private_contact_request` is: same arguments and null checks, `catch_spend_panic` and `map_spend_result` for the shielded spend contract, and a null-signer test. The docs of code 68 name it. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 6 +- .../src/private_contact_request.rs | 152 +++++++++++++++++- 3 files changed, 154 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 1d9f095a17b..301688c27b6 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -181,7 +181,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding | | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — an asset lock needs more inputs than one may carry (100 on Dash Core relay policy and Platform consensus): the full drain, or since #5712 a fixed amount held only in many small coins (identity registration, top-up, invitation, platform-address funding). Two meanings. On a build: nothing was broadcast, the reservation is released, no partial drain, and a rebuild after consolidating is safe. On the resume of a tracked `Built` / `Broadcast` lock over the cap (since #5714): it is not re-sent, but an earlier attempt may have reached peers; the row stays tracked and nothing is released. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | -| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the host accepts with `platform_wallet_manager_accept_private_contact_request` or sends with `platform_wallet_manager_send_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer | +| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the host accepts with `platform_wallet_manager_accept_contact_request_replying_in_kind` (which picks the reply itself, so a host calling it does not route on 68) or `platform_wallet_manager_accept_private_contact_request`, or sends with `platform_wallet_manager_send_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer | **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/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 5085f8047c3..e0e1ea2333f 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -628,8 +628,10 @@ pub enum PlatformWalletFFIResultCode { /// as a rotation, would publish the relationship. The public send and the /// public accept refuse it, and nothing was sent. Send it with /// `platform_wallet_manager_send_private_contact_request`, or accept it - /// with `platform_wallet_manager_accept_private_contact_request`, - /// instead. 61 to 67 are claimed by open branches, so this takes 68. + /// with `platform_wallet_manager_accept_contact_request_replying_in_kind` + /// (which replies privately itself) or + /// `platform_wallet_manager_accept_private_contact_request`, instead. + /// 61 to 67 are claimed by open branches, so this takes 68. ErrorPrivateContactRequestNeedsPrivateReply = 68, /// The named thing does not exist. diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index a8eea803720..82724044c13 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -17,13 +17,16 @@ //! 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. Built with the +//! `shielded` feature only. //! - [`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. //! -//! The public send and accept refuse a contact whose requests came or went -//! privately with `ErrorPrivateContactRequestNeedsPrivateReply`; the host -//! routes the request to the private send or accept. +//! The explicitly public send and accept refuse a contact whose requests came +//! or went privately with `ErrorPrivateContactRequestNeedsPrivateReply`. use platform_wallet::PrivateContact; use rs_sdk_ffi::MnemonicResolverHandle; @@ -534,6 +537,126 @@ unsafe fn accept_private_contact_request_inner( } } +/// Accept a DashPay contact request replying the way it came +/// (`PlatformWallet::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. +/// +/// Arguments and outputs are as for +/// [`platform_wallet_manager_accept_private_contact_request`]. The spend +/// authority is derived only for a private reply. Errors follow the shielded +/// spend contract: `ErrorShieldedSpendUnconfirmed` means the reply may +/// already be on chain; do not accept again. +/// +/// # Safety +/// Identical contract to +/// [`platform_wallet_manager_accept_private_contact_request`]. +#[cfg(feature = "shielded")] +#[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 { + // Same panic contract as the private accept: a private reply's notes may + // be spent when it strikes. + catch_spend_panic("contact request 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, + ) + }) +} + +/// Body of [`platform_wallet_manager_accept_contact_request_replying_in_kind`], +/// 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_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, 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 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( + &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) => map_spend_result(Err(error), "contact request accept"), + } +} + #[cfg(test)] mod tests { use super::*; @@ -636,6 +759,29 @@ mod tests { assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); } + /// As the private accept: whichever reply the wallet picks is signed by + /// the identity signer. + #[cfg(feature = "shielded")] + #[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); + } + #[test] fn should_carry_every_field_of_a_private_contact() { let address = [0x55; SHIELDED_ADDRESS_LEN]; From 9f01069ab253975e8f8a12119cd9d81188372386 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 20:32:30 +0700 Subject: [PATCH 10/37] feat(swift-sdk): accept a contact request replying in kind `PlatformWalletManager.acceptContactRequestReplyingInKind(walletId:resolver:account:request:shieldedAddress:signer:)` is a thin wrapper over the new FFI export: the wallet decides whether the reply is private or a document. The explicitly public and private accepts stay. The file header and the code 68 docs point hosts at it. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/PlatformWalletResult.swift | 3 +- .../PrivateContactRequests.swift | 61 +++++++++++++++++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 2ea5fa7e59c..9081b230dd2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -597,7 +597,8 @@ public enum PlatformWalletError: LocalizedError { /// (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.acceptPrivateContactRequest`, or send with + /// `PlatformWalletManager.acceptContactRequestReplyingInKind` (which picks + /// the reply itself) or `acceptPrivateContactRequest`, or send with /// `PlatformWalletManager.sendPrivateContactRequest`. case privateContactRequestNeedsPrivateReply(String) case walletAlreadyExists(String) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index 13debee5447..86a5f185d6b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -10,11 +10,13 @@ import DashSDKFFI // privately, and the contact's shielded address, are read with // `ManagedIdentity.privateContact(contactId:)`. // -// Routing: the public `acceptContactRequest` and `sendContactRequest` throw +// 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; accept it with -// `PlatformWalletManager.acceptPrivateContactRequest`, or send with -// `PlatformWalletManager.sendPrivateContactRequest`, instead. +// 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; only the @@ -244,6 +246,57 @@ extension PlatformWalletManager { 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. Throws + /// `PlatformWalletError.shieldedSpendUnconfirmed` when a private reply + /// may already be on chain: do not accept again. + 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 + ) + 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 From 1c07538424c0b4f5972f1cb98a74df72dce2887a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 20:32:30 +0700 Subject: [PATCH 11/37] refactor(swift-example-app): let the wallet choose how an accepted request is replied to The request list and Add Contact's collision alert call `acceptContactRequestReplyingInKind` once instead of trying the public accept and falling back to the private one on code 68. The `acceptIncomingContactRequest` helper that held that choice is removed. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 10 ++++---- .../Views/DashPay/ContactRequestsView.swift | 10 ++++---- .../Views/DashPay/ContactsView.swift | 23 ------------------- 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 6576a9006e1..66a1d16bca9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -512,10 +512,12 @@ struct AddContactView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - try await acceptIncomingContactRequest( - request, - wallet: wallet, - walletManager: walletManager, + // 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) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 99ba9062db5..d5c2a1f52a1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -228,10 +228,12 @@ struct ContactRequestsView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - try await acceptIncomingContactRequest( - request, - wallet: wallet, - walletManager: walletManager, + // 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index 4083f1e03a9..e75c18037cc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -235,29 +235,6 @@ func syncPrivateContactRequests(_ wallet: ManagedPlatformWallet) async { _ = try? await wallet.syncPrivateContactRequests() } -/// Accept `request`. Rust refuses a public reply to a contact whose requests -/// came privately, as it would publish the relationship; that one is accepted -/// with a private reply instead, paid from the wallet's shielded balance and -/// signed by the same `signer`. -@MainActor -func acceptIncomingContactRequest( - _ request: ContactRequest, - wallet: ManagedPlatformWallet, - walletManager: PlatformWalletManager, - signer: KeychainSigner -) async throws { - do { - _ = try await wallet.acceptContactRequest(request, signer: signer) - } catch PlatformWalletError.privateContactRequestNeedsPrivateReply { - _ = try await walletManager.acceptPrivateContactRequest( - walletId: wallet.walletId, - resolver: MnemonicResolver(), - request: request, - signer: signer - ) - } -} - // MARK: - Row model + view /// UI model for one established contact row, resolved from the From 5358ce1bf95cf37e040087439fa4f471b86c3ea2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:12:18 +0700 Subject: [PATCH 12/37] fix(rs-sdk-ffi): build the resolver signer's scan keys with the SDK's derive - `scan_private_document_page` builds its keys with `PrivateDocumentScanKeys::derive`, which wipes the derived keys on an error too. The signer keeps only the mnemonic resolve and the per-path derivation; its own scan-keys helper and `WipingSecretKeys` are gone. - `seal_private_contact_request` takes `platform_version` last and passes it to the SDK seal, which validates the payload with dpp. - Tests follow the SDK: signing data and seals take the platform version, and the read takes `ProvedIdentity` entries proved at the heights the scan needs. Co-Authored-By: Claude Opus 5.5 --- .../src/mnemonic_resolver_core_signer.rs | 91 ++++++++----------- 1 file changed, 39 insertions(+), 52 deletions(-) diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index f27eeb698ce..0da3c0dbd4e 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -65,9 +65,9 @@ use dash_sdk::dpp::identity::{Identity, KeyID}; use dash_sdk::dpp::platform_value::BinaryData; use dash_sdk::dpp::prelude::Identifier; use dash_sdk::dpp::secp256k1; +use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::dashpay::{ - private_contact_request_outgoing_viewing_key, seal_private_contact_request, - PrivateContactRequest, PrivateContactRequestSender, + seal_private_contact_request, PrivateContactRequest, PrivateContactRequestSender, }; use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; use dash_sdk::platform::private_document_scan::{PrivateDocumentScan, PrivateDocumentScanKeys}; @@ -622,8 +622,10 @@ impl MnemonicResolverCoreSigner { /// are the identities entries may be sealed to, each with the paths of /// its keys by key id; `senders` the identities that may have sealed /// entries, each with the id and path of an encryption key whose - /// outgoing viewing key recovers what it sent. Every key comes from one - /// resolve and is dropped here; only the sorted entries return. + /// outgoing viewing key recovers what it sent. The mnemonic is resolved + /// once and every key is derived from it here; the SDK builds the scan + /// keys from them ([`PrivateDocumentScanKeys::derive`]) and wipes them. + /// Only the sorted entries return. pub fn scan_private_document_page( &self, recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], @@ -638,7 +640,11 @@ impl MnemonicResolverCoreSigner { document_type_name, }; self.resolve_master(|master| { - let keys = private_document_scan_keys(master, recipients, senders)?; + // `secret_key_at` hands back a wiping guard; the copy it yields + // is wiped by `derive` once used. + let keys = PrivateDocumentScanKeys::derive(recipients, senders, |path| { + secret_key_at(master, path).map(|secret| secret.0) + })?; Ok(keys.scan_page(&target, start_position, page)) }) } @@ -665,6 +671,7 @@ impl MnemonicResolverCoreSigner { recipient_key_id: KeyID, request: &PrivateContactRequest, esk_seed: &[u8; 32], + platform_version: &PlatformVersion, ) -> Result { let secret = self.resolve_master(|master| secret_key_at(master, sender_encryption_key_path))?; @@ -681,6 +688,7 @@ impl MnemonicResolverCoreSigner { recipient_key_id, request, esk_seed, + platform_version, ) .map_err(MnemonicResolverSignerError::PrivateContactRequest) } @@ -700,34 +708,6 @@ fn secret_key_at( Ok(WipingSecretKey(secret)) } -/// The scan keys of `recipients` and `senders`, derived below `master`: each -/// recipient's secret keys, and the outgoing viewing key of each sender -/// encryption key ([`private_contact_request_outgoing_viewing_key`]). The -/// copies made here are wiped before this returns. -fn private_document_scan_keys( - master: &ExtendedPrivKey, - recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], - senders: &[(Identifier, KeyID, DerivationPath)], -) -> Result { - let mut keys = PrivateDocumentScanKeys::new(); - for (identity_id, paths) in recipients { - let mut secret_keys = WipingSecretKeys(Vec::with_capacity(paths.len())); - for (key_id, path) in paths { - secret_keys - .0 - .push((*key_id, secret_key_at(master, path)?.0)); - } - keys = keys.with_recipient(*identity_id, &secret_keys.0); - } - for (identity_id, key_id, path) in senders { - let secret = secret_key_at(master, path)?; - let outgoing_viewing_key = - Zeroizing::new(private_contact_request_outgoing_viewing_key(&secret.0)); - keys = keys.with_sender(*identity_id, *key_id, *outgoing_viewing_key); - } - Ok(keys) -} - /// Result of [`MnemonicResolverCoreSigner::contact_info_seal`]. pub struct ContactInfoSealed { /// `encToUserId` ciphertext (AES-256-ECB of the 32-byte contact id). @@ -753,17 +733,6 @@ impl Drop for WipingSecretKey { } } -/// Scrubs the secret keys of one scan recipient on every exit path. -struct WipingSecretKeys(Vec<(KeyID, secp256k1::SecretKey)>); - -impl Drop for WipingSecretKeys { - fn drop(&mut self) { - for (_, secret_key) in &mut self.0 { - secret_key.non_secure_erase(); - } - } -} - #[async_trait] impl Signer for MnemonicResolverCoreSigner { type Error = MnemonicResolverSignerError; @@ -1393,8 +1362,9 @@ mod tests { use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::dashpay::{ - private_contact_request_identities, private_contact_request_signature, - private_contact_request_signing_data, private_contact_requests_from_scan, + private_contact_request_identities, private_contact_request_outgoing_viewing_key, + private_contact_request_signature, private_contact_request_signing_data, + private_contact_requests_from_scan, ProvedIdentity, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, }; use dash_sdk::platform::private_document_entries::PrivateDocumentEntry; @@ -1605,6 +1575,7 @@ mod tests { recipient, RECIPIENT_KEY_ID, &request(), + PlatformVersion::latest(), ) .expect("the keys check"); private_contact_request_signature(&signer, &signing_key, &signing_data) @@ -1636,6 +1607,7 @@ mod tests { RECIPIENT_KEY_ID, &request(), &[esk_seed; 32], + PlatformVersion::latest(), ) .expect("the request seals") } @@ -1768,6 +1740,7 @@ mod tests { RECIPIENT_KEY_ID, &request(), &[7; 32], + PlatformVersion::latest(), ) .expect("the signer seals the request"); assert_eq!(sealed, sent_to_recipient(&contract, 7).await); @@ -1808,15 +1781,26 @@ mod tests { assert!(scan.rejected.is_empty()); assert!(scan.is_complete()); - let identities: BTreeMap<_, _> = [sender(), recipient()] - .into_iter() - .map(|identity| (identity.id(), Some(identity))) - .collect(); + let needed = private_contact_request_identities(&scan); assert_eq!( - private_contact_request_identities(&scan), - identities.keys().copied().collect(), + needed.keys().copied().collect::>(), + vec![sender().id(), recipient().id()], "the read checks the sender and the recipient" ); + // Each identity as a proof at the height the read needs shows it. + let identities: BTreeMap<_, _> = [sender(), recipient()] + .into_iter() + .map(|identity| { + let proved_at_height = needed[&identity.id()]; + ( + identity.id(), + ProvedIdentity { + identity: Some(identity), + proved_at_height, + }, + ) + }) + .collect(); let read = private_contact_requests_from_scan( &contract, scan, @@ -1896,6 +1880,7 @@ mod tests { RECIPIENT_KEY_ID, &request(), &[4; 32], + PlatformVersion::latest(), ) .expect("the signer seals the request"); assert_eq!(resolves.load(Ordering::SeqCst), 2); @@ -1931,6 +1916,7 @@ mod tests { RECIPIENT_KEY_ID, &request(), &[7; 32], + PlatformVersion::latest(), ) .expect_err("the key at that path is not the sender's"); assert!( @@ -1974,6 +1960,7 @@ mod tests { RECIPIENT_KEY_ID, &request(), &[7; 32], + PlatformVersion::latest(), ) .expect_err("the signature is not the signing key's"); assert!( From 0510a5bbec660449e527ebf686cb224f354c3bb5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:12:18 +0700 Subject: [PATCH 13/37] fix(platform-wallet): pay an in-kind accept's private reply only when it is one, in every build `platform_wallet_manager_accept_contact_request_replying_in_kind` no longer looks up the shielded coordinator or reads the mnemonic up front. It hands the wallet a payment that, only for a private reply, looks the coordinator up and resolves the seed on a blocking thread. A public or adopted reply therefore works on a manager without `platform_wallet_manager_configure_shielded` and reads no mnemonic for the payment (an FFI test pins both). A failure of that lookup returns the code the lookup reported. A reply for which no payment was asked keeps the codes the public accept returns; once one was asked, errors keep the shielded spend contract. The export and its JNI-facing contract exist in every build. Without the `shielded` feature it calls `DashPayView::accept_contact_request_replying_in_kind` with a payment that refuses: a request that needs a private reply fails with the new `ErrorShieldedBuildRequired` (69), and a public or adopted reply works as before. No existing code meant "shielded unsupported". Also: - `ErrorProtocolVersionNotActive` (70): the SDK's lasting `ProtocolVersionNotActive` refusal, such as the private store before protocol version 15, was `ErrorUnknown`; hosts can now stay quiet about it. A refusal that may lift soon keeps the catch-all. - A missing mnemonic while scanning or sealing the private store maps to the key-unavailable outcome (31), through the signer's position-0 marker in a `ProtocolError::Generic`, matched by variant; a test drives it through the resolver-backed provider. - The seal passes `platform_version`. - The private store sync runs on `run_on_big_stack_thread`: the SDK's identity fetch stream trips rust-lang/rust#100013 against `block_on_worker`'s `Send + 'static` bounds. Both codes are in `ERROR_CODE_REGISTRY.md`. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 6 +- .../rs-platform-wallet-ffi/src/dashpay.rs | 91 +++- packages/rs-platform-wallet-ffi/src/error.rs | 56 ++ .../src/private_contact_request.rs | 506 +++++++++++++++--- .../src/shielded_send.rs | 24 + 5 files changed, 610 insertions(+), 73 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 301688c27b6..5874c733b44 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 69**: 27–68 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 71**: 27–70 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister @@ -127,7 +127,7 @@ renumbered from 59 when #5322 took that number on `v5.1-dev`); 61–67 held by open branches with no row here yet (61 `ErrorAssetLockRecipientMismatch`, 62 both `ErrorShieldedClaimBindingMismatch` and `ErrorIdentityRevisionMismatch`, 63–66 the invitation-claim preflight, 67 `ErrorShieldedTreeInconsistent`); 68 -proposed for DashPay private contact requests; 28, 30, +to 70 proposed for DashPay private contact requests; 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -182,6 +182,8 @@ Fork-era numbers remain in the collision history, which is immutable record. | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — an asset lock needs more inputs than one may carry (100 on Dash Core relay policy and Platform consensus): the full drain, or since #5712 a fixed amount held only in many small coins (identity registration, top-up, invitation, platform-address funding). Two meanings. On a build: nothing was broadcast, the reservation is released, no partial drain, and a rebuild after consolidating is safe. On the resume of a tracked `Built` / `Broadcast` lock over the cap (since #5714): it is not re-sent, but an earlier attempt may have reached peers; the row stays tracked and nothing is released. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | | 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the host accepts with `platform_wallet_manager_accept_contact_request_replying_in_kind` (which picks the reply itself, so a host calling it does not route on 68) or `platform_wallet_manager_accept_private_contact_request`, or sends with `platform_wallet_manager_send_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer | +| 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 | **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/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 0dbe5026682..b1c3135d6cb 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -41,6 +41,8 @@ use dash_sdk::platform::private_document_scan::PrivateDocumentScan; use dpp::identity::{Identity, KeyID}; use dpp::platform_value::BinaryData; use dpp::prelude::{DataContract, Identifier}; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; use platform_wallet::{ContactRequest, PlatformWalletError, PrivateDocumentKeyPaths}; use rs_sdk_ffi::{ MnemonicResolverCoreSigner, MnemonicResolverHandle, MnemonicResolverSignerError, SignerHandle, @@ -1276,6 +1278,7 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { recipient_key_id: KeyID, request: &PrivateContactRequest, esk_seed: &[u8; 32], + platform_version: &PlatformVersion, ) -> Result { self.signer .seal_private_contact_request( @@ -1289,19 +1292,26 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { recipient_key_id, request, esk_seed, + platform_version, ) .map_err(private_document_signer_error) } } /// A signer failure of a private document operation as a wallet error: the -/// SDK's refusal stays typed, the rest are reported as the other provider -/// methods report theirs. +/// SDK's refusal stays typed; a missing mnemonic is the key-unavailable +/// outcome (FFI code 31), carried as the signer's typed completion is, with +/// the reserved marker at position 0 of a `ProtocolError::Generic` payload +/// (`NotFound` renders it there), so the structural discriminator restores +/// it; the rest are reported as the other provider methods report theirs. fn private_document_signer_error(error: MnemonicResolverSignerError) -> PlatformWalletError { match error { MnemonicResolverSignerError::PrivateContactRequest(error) => { PlatformWalletError::Sdk(error) } + error @ MnemonicResolverSignerError::NotFound => PlatformWalletError::Sdk( + dash_sdk::Error::Protocol(ProtocolError::Generic(error.to_string())), + ), error => PlatformWalletError::InvalidIdentityData(error.to_string()), } } @@ -1735,6 +1745,83 @@ 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 { + 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) }; + } + } + // Marshalling-boundary coverage for the verify entry point, replacing what // the removed attach FFI's input-validation tests upheld. The crypto // semantics (matching seed binds, wrong seed rejected) are pinned library- diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index e0e1ea2333f..5c43e898632 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -634,6 +634,22 @@ pub enum PlatformWalletFFIResultCode { /// 61 to 67 are claimed by open branches, so this takes 68. ErrorPrivateContactRequestNeedsPrivateReply = 68, + /// The operation needs the shielded pool, and this library was built + /// without the `shielded` feature: today, accepting a contact request + /// that came privately with + /// `platform_wallet_manager_accept_contact_request_replying_in_kind`, + /// whose private reply is paid from the pool. Nothing was sent. Not + /// retryable on this build; a build with shielded support accepts it. + ErrorShieldedBuildRequired = 69, + + /// The SDK refused the request without sending it: the network is proven + /// at a protocol version below the one that serves it (for example + /// DashPay's private contact request store before protocol version 15), + /// or the SDK is pinned below it. Expected on such a network; it lifts + /// when the network upgrades. A refusal that may lift sooner, because the + /// network's version could not be confirmed, keeps the catch-all code. + ErrorProtocolVersionNotActive = 70, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1186,6 +1202,15 @@ impl From for PlatformWalletFFIResult { // `MessageSigningKeyUnavailable` mapped above. By the time a // `MessageSigningFailed` reason exists, any marker in it sits // mid-string and is deliberately not matched. + // The network does not serve the request yet, as its proven + // protocol version shows. A refusal that may lift soon (the query + // for the version failed for a reason that can pass) stays on the + // catch-all: that is the network failing, which a host surfaces. + PlatformWalletError::Sdk( + sdk_error @ dash_sdk::Error::ProtocolVersionNotActive { .. }, + ) if !sdk_error.is_transient_protocol_version_refusal() => { + PlatformWalletFFIResultCode::ErrorProtocolVersionNotActive + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; let result = PlatformWalletFFIResult::err(code, error.to_string()); @@ -2715,6 +2740,37 @@ mod identity_balance_error_tests { mod private_contact_request_error_tests { use super::*; + /// A store the network does not serve yet (DashPay's private contact + /// requests before protocol version 15) is told apart from a failure, so + /// a host can stay quiet about it; a refusal that may lift soon, because + /// the version could not be confirmed, is still the network failing. + #[test] + fn should_tell_a_store_the_network_does_not_serve_yet_by_its_own_code() { + let refused = |cause: Option>| { + PlatformWalletFFIResult::from(PlatformWalletError::Sdk( + dash_sdk::Error::ProtocolVersionNotActive { + method: "get_private_document_entries", + required: 15, + feature: None, + network: 14, + pinned: false, + cause, + }, + )) + }; + let lasting = refused(None); + assert_eq!( + lasting.code, + PlatformWalletFFIResultCode::ErrorProtocolVersionNotActive + ); + assert_eq!(lasting.code as u32, 70); + + let transient = refused(Some(Box::new(dash_sdk::Error::Generic( + "no node answered".to_string(), + )))); + assert_eq!(transient.code, PlatformWalletFFIResultCode::ErrorUnknown); + } + #[test] fn should_tell_a_private_request_from_other_accept_failures_by_code() { let result = PlatformWalletFFIResult::from( diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index 82724044c13..c5eb551ab44 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -19,8 +19,9 @@ //! `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. Built with the -//! `shielded` feature only. +//! 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. @@ -28,39 +29,45 @@ //! The explicitly public send and accept refuse a contact whose requests came //! or went privately with `ErrorPrivateContactRequestNeedsPrivateReply`. -use platform_wallet::PrivateContact; -use rs_sdk_ffi::MnemonicResolverHandle; -#[cfg(feature = "shielded")] -use rs_sdk_ffi::{SignerHandle, VTableSigner}; +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::runtime::{block_on_worker, run_on_big_stack_thread, runtime}; 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; +use platform_wallet::wallet::shielded::{CachedOrchardProver, NetworkShieldedCoordinator}; #[cfg(feature = "shielded")] -use platform_wallet::AutoAcceptProofSource; - +use platform_wallet::{AutoAcceptProofSource, PrivateReplyPayment}; #[cfg(feature = "shielded")] -use crate::contact_request::CONTACT_REQUEST_STORAGE; +use zeroize::Zeroizing; + #[cfg(feature = "shielded")] use crate::dashpay::read_auto_accept_proof; #[cfg(feature = "shielded")] -use crate::established_contact::ESTABLISHED_CONTACT_STORAGE; -#[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_wallet_and_coordinator, + 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 @@ -145,12 +152,26 @@ pub unsafe extern "C" fn platform_wallet_sync_private_contact_requests( // 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 + // `run_on_big_stack_thread` rather than `block_on_worker`: the pass's + // future trips rustc's implied-lifetime-bound limitation (rust-lang/rust + // issue #100013) against `block_on_worker`'s `Send + 'static` bounds, + // through the SDK's identity fetch stream. + let spawned = run_on_big_stack_thread(|| { + runtime().block_on( + identity + .dashpay() + .sync_private_contact_requests(&provider, max_pages as usize), + ) }); + let result = match spawned { + Ok(result) => result, + Err(error) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("failed to spawn big-stack thread for the private contact sync: {error}"), + ); + } + }; let report = unwrap_result_or_return!(result); unsafe { *out_report = PrivateContactSyncReportFFI { @@ -242,7 +263,6 @@ pub unsafe extern "C" fn managed_identity_get_private_contact( /// /// # Safety /// A non-null `ptr` must point to [`SHIELDED_ADDRESS_LEN`] readable bytes. -#[cfg(feature = "shielded")] unsafe fn read_shielded_address(ptr: *const u8) -> Option<[u8; SHIELDED_ADDRESS_LEN]> { if ptr.is_null() { return None; @@ -538,24 +558,39 @@ unsafe fn accept_private_contact_request_inner( } /// Accept a DashPay contact request replying the way it came -/// (`PlatformWallet::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. +/// (`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. /// -/// Arguments and outputs are as for -/// [`platform_wallet_manager_accept_private_contact_request`]. The spend -/// authority is derived only for a private reply. Errors follow the shielded -/// spend contract: `ErrorShieldedSpendUnconfirmed` means the reply may -/// already be on chain; do not accept again. +/// 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 -/// Identical contract to -/// [`platform_wallet_manager_accept_private_contact_request`]. -#[cfg(feature = "shielded")] +/// - `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( @@ -568,9 +603,7 @@ pub unsafe extern "C" fn platform_wallet_manager_accept_contact_request_replying signer_handle: *mut SignerHandle, out_established_handle: *mut Handle, ) -> PlatformWalletFFIResult { - // Same panic contract as the private accept: a private reply's notes may - // be spent when it strikes. - catch_spend_panic("contact request accept", || { + let accept = || { accept_contact_request_replying_in_kind_inner( handle, wallet_id_bytes, @@ -581,15 +614,98 @@ pub unsafe extern "C" fn platform_wallet_manager_accept_contact_request_replying 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`]. +/// 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. -#[cfg(feature = "shielded")] #[allow(clippy::too_many_arguments)] unsafe fn accept_contact_request_replying_in_kind_inner( handle: Handle, @@ -613,12 +729,8 @@ unsafe fn accept_contact_request_replying_in_kind_inner( 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, + 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 @@ -627,33 +739,111 @@ unsafe fn accept_contact_request_replying_in_kind_inner( 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, + |_entry| 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 + }) + }; - let result = 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( - &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) => map_spend_result(Err(error), "contact request accept"), + Err(error) => { + if let Some((code, message)) = payment_failure_slot + .lock() + .ok() + .and_then(|mut slot| slot.take()) + { + return PlatformWalletFFIResult::err(code, message); + } + #[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) + } } } @@ -761,7 +951,6 @@ mod tests { /// As the private accept: whichever reply the wallet picks is signed by /// the identity signer. - #[cfg(feature = "shielded")] #[test] fn should_reject_a_null_identity_signer_on_the_accept_replying_in_kind() { let wallet_id = [1u8; 32]; @@ -782,6 +971,185 @@ mod tests { 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) }; + } + #[test] fn should_carry_every_field_of_a_private_contact() { let address = [0x55; SHIELDED_ADDRESS_LEN]; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 0564ad299fc..532b84edda7 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -2327,6 +2327,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::*; From 0e210ed73f36b284243c0bc9bd0ceaaac7558ebd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:12:18 +0700 Subject: [PATCH 14/37] fix(swift-sdk): count private contact calls as native operations and type codes 69 and 70 - `sendPrivateContactRequest`, `acceptPrivateContactRequest` and `acceptContactRequestReplyingInKind` are admitted with `admitNativeOp` and finished with `finishNativeOp`, so a teardown waits for them as it does for the other counted manager calls. - `PlatformWalletError.shieldedBuildRequired` (69) and `.protocolVersionNotActive` (70), with `ErrorHandlingTests` pins. - The in-kind accept's docs say a public or adopted reply needs neither shielded support nor a payment mnemonic read. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/PlatformWalletResult.swift | 29 +++++++++++++++++ .../PrivateContactRequests.swift | 21 +++++++++++-- .../ErrorHandlingTests.swift | 31 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 9081b230dd2..17ed64cf4bb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -269,6 +269,15 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// 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 /// 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. @@ -402,6 +411,10 @@ public enum PlatformWalletResultCode: Int32, Sendable { 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_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -601,6 +614,17 @@ public enum PlatformWalletError: LocalizedError { /// 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) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -827,6 +851,7 @@ public enum PlatformWalletError: LocalizedError { .assetLockFundingMismatch(let m), .assetLockInsufficientFunds(let m), .assetLockTooManyInputs(let m), .privateContactRequestNeedsPrivateReply(let m), + .shieldedBuildRequired(let m), .protocolVersionNotActive(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -947,6 +972,10 @@ public enum PlatformWalletError: LocalizedError { case .errorAssetLockTooManyInputs: self = .assetLockTooManyInputs(detail) case .errorPrivateContactRequestNeedsPrivateReply: self = .privateContactRequestNeedsPrivateReply(detail) + case .errorShieldedBuildRequired: + self = .shieldedBuildRequired(detail) + case .errorProtocolVersionNotActive: + self = .protocolVersionNotActive(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 index 86a5f185d6b..756fbd01e99 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -160,6 +160,10 @@ extension PlatformWalletManager { 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 @@ -219,6 +223,10 @@ extension PlatformWalletManager { 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) { @@ -252,9 +260,12 @@ extension PlatformWalletManager { /// `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. Throws - /// `PlatformWalletError.shieldedSpendUnconfirmed` when a private reply - /// may already be on chain: do not accept again. + /// 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: 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, @@ -268,6 +279,10 @@ extension PlatformWalletManager { 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) { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 0cedd0ba809..2627d093dcc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -475,6 +475,37 @@ final class ErrorHandlingTests: XCTestCase { 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 testShouldReadEveryFieldOfAPrivateContact() { var ffi = PrivateContactFFI() ffi.received_privately = true From 28ddeb84dc728bc1207e993289270a230c517d46 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:12:18 +0700 Subject: [PATCH 15/37] fix(swift-example-app): keep unconfirmed private submissions pending and report private refresh failures - A private send or an in-kind accept whose result could not be confirmed (`shieldedSpendUnconfirmed`) stays pending, as `SendViewModel` treats an unconfirmed spend: Add Contact shows a Done-only notice and no longer enables Send, and the request row keeps its pending state without its buttons until the next sync settles it. - The request list's private refresh stays quiet when the network does not serve the private store yet (code 70), and shows any other failure, and requests read but not saved, in a notice above the list. - `ContactRequestSubmissionOutcome` and `PrivateContactRefreshNotice` hold those two readings, pinned by `ContactRequestOutcomesTests`. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 34 ++++++++++++- .../DashPay/ContactRequestOutcomes.swift | 50 +++++++++++++++++++ .../Views/DashPay/ContactRequestsView.swift | 42 ++++++++++++++-- .../Views/DashPay/ContactsView.swift | 19 +++++-- .../ContactRequestOutcomesTests.swift | 47 +++++++++++++++++ 5 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 66a1d16bca9..a72cf1021c6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -57,6 +57,11 @@ struct AddContactView: View { @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? + /// Send-collision flow. @State private var showCollisionAlert = false @State private var collisionRecipient: Identifier? @@ -94,6 +99,7 @@ struct AddContactView: View { resolvedRecipient != nil && resolvedRecipient != identity.identityId && !isSending + && unconfirmedNotice == nil } var body: some View { @@ -154,6 +160,16 @@ struct AddContactView: View { } message: { _ in Text("This person already sent you a request — accept it instead?") } + .alert( + "Submitted", + isPresented: .constant(unconfirmedNotice != nil) + ) { + Button("Done") { dismiss() } + } message: { + if let unconfirmedNotice { + Text(unconfirmedNotice) + } + } } } @@ -489,7 +505,15 @@ struct AddContactView: View { 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 .failed(let message): + errorMessage = message + } } } } @@ -523,7 +547,13 @@ struct AddContactView: View { kickDashPaySync(walletManager) dismiss() } catch { - errorMessage = "Accept failed: \(error.localizedDescription)" + switch ContactRequestSubmissionOutcome.of(error) { + case .mayHaveGoneThrough: + kickDashPaySync(walletManager) + unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + case .failed(let message): + errorMessage = "Accept failed: \(message)" + } } } } 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..7d01c7dde9e --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -0,0 +1,50 @@ +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. 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 + /// 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 func of(_ error: Error) -> ContactRequestSubmissionOutcome { + if let walletError = error as? PlatformWalletError, + case .shieldedSpendUnconfirmed = walletError { + return .mayHaveGoneThrough + } + return .failed(error.localizedDescription) + } +} + +/// 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 d5c2a1f52a1..2c7ca07d4d8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -41,6 +41,14 @@ struct ContactRequestsView: View { /// with an inline error on the row). @State private var rowErrors: [Data: String] = [:] + /// Contact ids whose accept may have gone through (a private reply whose + /// result could not be confirmed): the row stays pending, without its + /// buttons, until the next sync settles it. Never offered again here. + @State private var unconfirmedIds: Set = [] + + /// 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 +113,7 @@ struct ContactRequestsView: View { if incomingPending.isEmpty && outgoingPending.isEmpty && optimisticOutgoing.isEmpty { List { + privateRefreshNoticeSection DashPayListEmptyRow( icon: "tray", title: "No pending requests", @@ -114,6 +123,7 @@ struct ContactRequestsView: View { .listStyle(.insetGrouped) } else { List { + privateRefreshNoticeSection if !incomingPending.isEmpty { Section { ForEach(incomingPending, id: \.contactIdentityId) { row in @@ -126,8 +136,11 @@ 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) + || unconfirmedIds.contains(row.contactIdentityId), + errorMessage: unconfirmedIds.contains(row.contactIdentityId) + ? ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + : rowErrors[row.contactIdentityId], onAccept: { accept(contactId: row.contactIdentityId) }, onIgnore: { ignore(contactId: row.contactIdentityId) } ) @@ -166,7 +179,7 @@ struct ContactRequestsView: View { .refreshable { await attachOrStartSync(walletManager) if let wallet = try? requireWallet() { - await syncPrivateContactRequests(wallet) + privateRefreshNotice = await syncPrivateContactRequests(wallet) } } .onChange(of: rowSignature) { _, _ in @@ -184,6 +197,19 @@ struct ContactRequestsView: View { } } + /// 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") + } + } + } + // MARK: - Overlay maintenance /// Drop overlay entries the `@Query` already reflects: @@ -241,7 +267,15 @@ struct ContactRequestsView: View { 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 next sync settles it. + unconfirmedIds.insert(contactId) + kickDashPaySync(walletManager) + case .failed(let message): + rowErrors[contactId] = "Accept failed: \(message)" + } } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index e75c18037cc..c423c7f7f27 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -227,12 +227,21 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { } /// Read DashPay's private contact request store (protocol version 15) for -/// `wallet`. Best effort: a network before protocol version 15 has no such -/// store. Private requests reach the wallet only through this read, so it -/// runs with every pull-to-refresh of the request list. +/// `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 { - _ = try? await wallet.syncPrivateContactRequests() +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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift new file mode 100644 index 00000000000..14b383204d6 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -0,0 +1,47 @@ +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) + } + + 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." + ) + } +} From 91910099945e71b1d6b2d308f3af71325a190b7f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:00:19 +0700 Subject: [PATCH 16/37] fix(platform-wallet): give a pending private contact request its own code and read it per contact - `PlatformWalletError::PrivateContactRequestPending` maps to the new `ErrorPrivateContactRequestPending` (71), both in the blanket conversion and in the shielded spend contract the private send and accepts use, which would otherwise flatten it to `ErrorWalletOperation`. A private request to that contact is in flight or paid and not yet read back; nothing new was paid, and a later private sync settles it. Recorded in `ERROR_CODE_REGISTRY.md`. - `managed_identity_get_private_reply_pending(identity_handle, contact_id, out_pending)` is a pure read of `DashPayState::private_reply_pending`, so hosts disable a second send or accept from wallet-owned state. It is a getter rather than a `PrivateContactFFI` field because the latter answers NotFound until a request has come or gone privately, while a first private send is already pending. - The private store sync runs on `block_on_worker` again: the SDK's identity fetch future is `Send` now, so the big-stack thread is no longer needed (`block_on_worker` already polls on the 8 MB worker stack). Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 5 +- packages/rs-platform-wallet-ffi/src/error.rs | 35 ++++++ .../src/private_contact_request.rs | 106 ++++++++++++++---- .../src/shielded_send.rs | 4 + 4 files changed, 128 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 5874c733b44..4b2c4501e83 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 71**: 27–70 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 72**: 27–71 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister @@ -127,7 +127,7 @@ renumbered from 59 when #5322 took that number on `v5.1-dev`); 61–67 held by open branches with no row here yet (61 `ErrorAssetLockRecipientMismatch`, 62 both `ErrorShieldedClaimBindingMismatch` and `ErrorIdentityRevisionMismatch`, 63–66 the invitation-claim preflight, 67 `ErrorShieldedTreeInconsistent`); 68 -to 70 proposed for DashPay private contact requests; 28, 30, +to 71 proposed for DashPay private contact requests; 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -184,6 +184,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the host accepts with `platform_wallet_manager_accept_contact_request_replying_in_kind` (which picks the reply itself, so a host calling it does not route on 68) or `platform_wallet_manager_accept_private_contact_request`, or sends with `platform_wallet_manager_send_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer | | 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. Nothing new was paid; a later private contact request sync 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 | **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/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 5c43e898632..7455c6d30be 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -650,6 +650,14 @@ pub enum PlatformWalletFFIResultCode { /// network's version could not be confirmed, keeps the catch-all code. ErrorProtocolVersionNotActive = 70, + /// Maps `PlatformWalletError::PrivateContactRequestPending`: a private + /// contact request to that identity is in flight (another send or reply + /// reserved it), or was paid for and is not yet read back from the store. + /// Nothing new was paid. Do not submit it again: a later private contact + /// request sync (`platform_wallet_sync_private_contact_requests`) + /// settles it. + ErrorPrivateContactRequestPending = 71, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1093,6 +1101,12 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::PrivateContactRequestNeedsPrivateReply(..) => { PlatformWalletFFIResultCode::ErrorPrivateContactRequestNeedsPrivateReply } + // A private request to the contact is in flight or paid and not + // yet read back: pending, not failed, so the host must not submit + // it again. + PlatformWalletError::PrivateContactRequestPending(..) => { + PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending + } // A signer failure can also reach this blanket impl wrapped as // `PlatformWalletError::Sdk(dash_sdk::Error::Protocol(..))` (any // wallet operation that propagates the SDK error via `?`). The @@ -2739,6 +2753,8 @@ mod identity_balance_error_tests { #[cfg(test)] mod private_contact_request_error_tests { use super::*; + #[cfg(feature = "shielded")] + use crate::shielded_send::map_spend_result; /// A store the network does not serve yet (DashPay's private contact /// requests before protocol version 15) is told apart from a failure, so @@ -2783,4 +2799,23 @@ mod private_contact_request_error_tests { assert_eq!(result.code as u32, 68); assert!(!result.message.is_null()); } + + /// A private request in flight, or paid and not yet read back, is pending + /// rather than failed, whichever path reports it: the blanket conversion, + /// and the shielded spend contract the private send and accepts use. + #[test] + fn should_report_a_pending_private_request_by_its_own_code() { + let pending = || PlatformWalletError::PrivateContactRequestPending([7; 32].into()); + let result = PlatformWalletFFIResult::from(pending()); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending + ); + assert_eq!(result.code as u32, 71); + #[cfg(feature = "shielded")] + assert_eq!( + map_spend_result(Err(pending()), "private contact request").code, + PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending + ); + } } diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index c5eb551ab44..c477198941a 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -25,6 +25,9 @@ //! - [`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`. @@ -39,7 +42,7 @@ 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, run_on_big_stack_thread, runtime}; +use crate::runtime::block_on_worker; use crate::types::*; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; @@ -152,26 +155,12 @@ pub unsafe extern "C" fn platform_wallet_sync_private_contact_requests( // call. let provider = unsafe { resolver_contact_crypto_provider(core_signer_handle, wallet_id, network) }; - // `run_on_big_stack_thread` rather than `block_on_worker`: the pass's - // future trips rustc's implied-lifetime-bound limitation (rust-lang/rust - // issue #100013) against `block_on_worker`'s `Send + 'static` bounds, - // through the SDK's identity fetch stream. - let spawned = run_on_big_stack_thread(|| { - runtime().block_on( - identity - .dashpay() - .sync_private_contact_requests(&provider, max_pages as usize), - ) + let result = block_on_worker(async move { + identity + .dashpay() + .sync_private_contact_requests(&provider, max_pages as usize) + .await }); - let result = match spawned { - Ok(result) => result, - Err(error) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - format!("failed to spawn big-stack thread for the private contact sync: {error}"), - ); - } - }; let report = unwrap_result_or_return!(result); unsafe { *out_report = PrivateContactSyncReportFFI { @@ -259,6 +248,35 @@ pub unsafe extern "C" fn managed_identity_get_private_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 @@ -1150,6 +1168,54 @@ mod tests { 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]; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 532b84edda7..ceb21c93a5c 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -620,6 +620,10 @@ pub(crate) fn map_spend_result( Err(e @ PlatformWalletError::ShieldedIdentityDebitPending { .. }) | Err(e @ PlatformWalletError::ShieldedRecoveryCorrupted { .. }) | 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. + Err(e @ PlatformWalletError::PrivateContactRequestPending(..)) => 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 From 00487b02265eb06b461aee7f3656a9d50dbd7ea9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:00:19 +0700 Subject: [PATCH 17/37] feat(swift-sdk): type a pending private contact request and read it per contact - `PlatformWalletError.privateContactRequestPending` (71), with an `ErrorHandlingTests` pin. The private send, private accept and in-kind accept docs name it. - `ManagedIdentity.privateReplyPending(contactId:)` wraps `managed_identity_get_private_reply_pending`. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/PlatformWalletResult.swift | 15 +++++++++++ .../PrivateContactRequests.swift | 27 ++++++++++++++++--- .../ErrorHandlingTests.swift | 15 +++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 17ed64cf4bb..7e205db7fee 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -278,6 +278,10 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// 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 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. @@ -415,6 +419,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { 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_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -625,6 +631,12 @@ public enum PlatformWalletError: LocalizedError { /// 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. Nothing new was paid; a later + /// `ManagedPlatformWallet.syncPrivateContactRequests` settles it. Pending, + /// not failed: do not submit it again. + case privateContactRequestPending(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -852,6 +864,7 @@ public enum PlatformWalletError: LocalizedError { .assetLockTooManyInputs(let m), .privateContactRequestNeedsPrivateReply(let m), .shieldedBuildRequired(let m), .protocolVersionNotActive(let m), + .privateContactRequestPending(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -976,6 +989,8 @@ public enum PlatformWalletError: LocalizedError { self = .shieldedBuildRequired(detail) case .errorProtocolVersionNotActive: self = .protocolVersionNotActive(detail) + case .errorPrivateContactRequestPending: + self = .privateContactRequestPending(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 index 756fbd01e99..50522dbd86c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -77,6 +77,22 @@ extension ManagedIdentity { 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. Rust keeps it in memory only, so it is false after a relaunch. + 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 { @@ -143,7 +159,9 @@ extension PlatformWalletManager { /// signs the request with one of the sender's authentication keys, as it /// signs a public request's transition. Throws /// `PlatformWalletError.shieldedSpendUnconfirmed` when the payment may - /// already be on chain: do not send again. + /// already be on chain, and `.privateContactRequestPending` when a + /// private request to the recipient is in flight or paid and not yet read + /// back: either way do not send again; a later private sync settles it. public func sendPrivateContactRequest( walletId: Data, resolver: MnemonicResolver, @@ -209,7 +227,8 @@ extension PlatformWalletManager { /// 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. + /// `acceptContactRequest` takes; it signs the reply. An unconfirmed or + /// pending reply throws as `sendPrivateContactRequest` does. public func acceptPrivateContactRequest( walletId: Data, resolver: MnemonicResolver, @@ -263,7 +282,9 @@ extension PlatformWalletManager { /// 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: do not accept again; and + /// 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( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 2627d093dcc..df94cd5b6f5 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -506,6 +506,21 @@ final class ErrorHandlingTests: XCTestCase { 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) + } + func testShouldReadEveryFieldOfAPrivateContact() { var ffi = PrivateContactFFI() ffi.received_privately = true From a69a15363797c88d0145e1d9dd74a9e552234626 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:00:19 +0700 Subject: [PATCH 18/37] fix(swift-example-app): read a pending private reply from the wallet The request rows and Add Contact's Send read whether the wallet has a private request to the contact in flight or not yet read back (`privateRepliesPending`, over `ManagedIdentity.privateReplyPending`), instead of keeping an unconfirmed accept in screen-local state. While it is set, the row shows pending without its buttons and Send is off; it clears when a private sync settles it, and it holds when the screen is left and reopened. The views re-read it on appear, after an accept or send, and after each sync pass. `privateContactRequestPending` (71) is the same outcome as an unconfirmed spend: pending, no fresh submission. `ContactRequestOutcomesTests` covers it and the row message. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 29 ++++++++++++++ .../DashPay/ContactRequestOutcomes.swift | 22 +++++++++-- .../Views/DashPay/ContactRequestsView.swift | 39 ++++++++++++++----- .../Views/DashPay/ContactsView.swift | 19 +++++++++ .../ContactRequestOutcomesTests.swift | 19 +++++++++ 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index a72cf1021c6..c5d35021ecf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -62,6 +62,12 @@ struct AddContactView: View { /// 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; a private sync settles it. Read from the wallet, + /// so reopening this sheet keeps it. + @State private var recipientPending = false + /// Send-collision flow. @State private var showCollisionAlert = false @State private var collisionRecipient: Identifier? @@ -100,6 +106,7 @@ struct AddContactView: View { && resolvedRecipient != identity.identityId && !isSending && unconfirmedNotice == nil + && !recipientPending } var body: some View { @@ -138,6 +145,7 @@ struct AddContactView: View { } } .navigationTitle("Add Contact") + .task(id: resolvedRecipient) { reloadRecipientPending() } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -404,7 +412,26 @@ struct AddContactView: View { } .disabled(!canSend) .accessibilityIdentifier("dashpay.addContact.send") + } footer: { + if recipientPending { + Text(ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage) + .accessibilityIdentifier("dashpay.addContact.pending") + } + } + } + + /// Re-read from the wallet whether a private request to the resolved + /// recipient is pending. + private func reloadRecipientPending() { + guard let recipient = resolvedRecipient, let wallet = try? requireWallet() else { + recipientPending = false + return } + recipientPending = privateRepliesPending( + wallet: wallet, + identityId: identity.identityId, + contactIds: [recipient] + ).contains(recipient) } private func previewDisplayName( @@ -514,6 +541,7 @@ struct AddContactView: View { case .failed(let message): errorMessage = message } + reloadRecipientPending() } } } @@ -554,6 +582,7 @@ struct AddContactView: View { 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 index 7d01c7dde9e..70e1e28e28c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -5,7 +5,10 @@ import SwiftDashSDK /// 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. Every other error is an ordinary failure the user may retry. +/// settles it. A private request Rust reports as pending (one to the same +/// contact in flight, or paid and not yet read back) is the same outcome: +/// nothing new was paid, and the next private sync settles 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 @@ -15,10 +18,21 @@ enum ContactRequestSubmissionOutcome: Equatable { static let mayHaveGoneThroughMessage = "This may have gone through. It shows up " + "after the next sync confirms it; do not submit it again." + /// The message a request row shows: the pending notice while the wallet + /// has a private reply to the contact pending, whatever error an earlier + /// attempt left, and that error otherwise. + static func rowMessage(pending: Bool, error: String?) -> String? { + pending ? mayHaveGoneThroughMessage : error + } + static func of(_ error: Error) -> ContactRequestSubmissionOutcome { - if let walletError = error as? PlatformWalletError, - case .shieldedSpendUnconfirmed = walletError { - return .mayHaveGoneThrough + if let walletError = error as? PlatformWalletError { + switch walletError { + case .shieldedSpendUnconfirmed, .privateContactRequestPending: + return .mayHaveGoneThrough + default: + break + } } return .failed(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 2c7ca07d4d8..2ccaa3ab012 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -41,10 +41,12 @@ struct ContactRequestsView: View { /// with an inline error on the row). @State private var rowErrors: [Data: String] = [:] - /// Contact ids whose accept may have gone through (a private reply whose - /// result could not be confirmed): the row stays pending, without its - /// buttons, until the next sync settles it. Never offered again here. - @State private var unconfirmedIds: Set = [] + /// 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. + @State private var walletPendingIds: Set = [] /// What the last private-store refresh has to tell the user, if anything. @State private var privateRefreshNotice: String? @@ -137,10 +139,11 @@ struct ContactRequestsView: View { avatarUrl: nil, createdAtMillis: row.createdAtMillis, isInFlight: inFlightIds.contains(row.contactIdentityId) - || unconfirmedIds.contains(row.contactIdentityId), - errorMessage: unconfirmedIds.contains(row.contactIdentityId) - ? ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage - : rowErrors[row.contactIdentityId], + || walletPendingIds.contains(row.contactIdentityId), + errorMessage: ContactRequestSubmissionOutcome.rowMessage( + pending: walletPendingIds.contains(row.contactIdentityId), + error: rowErrors[row.contactIdentityId] + ), onAccept: { accept(contactId: row.contactIdentityId) }, onIgnore: { ignore(contactId: row.contactIdentityId) } ) @@ -181,9 +184,12 @@ struct ContactRequestsView: View { if let wallet = try? requireWallet() { privateRefreshNotice = await syncPrivateContactRequests(wallet) } + reloadPendingReplies() } + .task { reloadPendingReplies() } .onChange(of: rowSignature) { _, _ in pruneOverlays() + reloadPendingReplies() } .onChange(of: walletManager.dashPaySyncIsSyncing) { _, syncing in // Fallback clearing rule: after the next completed @@ -193,6 +199,7 @@ struct ContactRequestsView: View { if !syncing { removedOverlayIds.removeAll() optimisticSentIds.removeAll() + reloadPendingReplies() } } } @@ -210,6 +217,17 @@ struct ContactRequestsView: View { } } + /// Re-read which incoming rows have a private reply pending, from the + /// wallet. + private func reloadPendingReplies() { + guard let wallet = try? requireWallet() else { return } + walletPendingIds = privateRepliesPending( + wallet: wallet, + identityId: identity.identityId, + contactIds: incomingPending.map(\.contactIdentityId) + ) + } + // MARK: - Overlay maintenance /// Drop overlay entries the `@Query` already reflects: @@ -270,13 +288,14 @@ struct ContactRequestsView: View { switch ContactRequestSubmissionOutcome.of(error) { case .mayHaveGoneThrough: // As `SendViewModel` treats an unconfirmed spend: not a - // failure to retry. The next sync settles it. - unconfirmedIds.insert(contactId) + // failure to retry. The wallet keeps the reply pending, + // and the row reads that; the next sync settles it. kickDashPaySync(walletManager) 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 c423c7f7f27..3d7d9109f00 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -226,6 +226,25 @@ 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 private send or reply to it, so the views show it as +/// pending and offer neither; a private sync settles it. Empty when the +/// identity is not managed. +@MainActor +func privateRepliesPending( + wallet: ManagedPlatformWallet, + identityId: Data, + contactIds: [Data] +) -> Set { + guard !contactIds.isEmpty, + let managed = try? wallet.managedIdentity(identityId: identityId) else { + return [] + } + return Set(contactIds.filter { (try? managed.privateReplyPending(contactId: $0)) == true }) +} + /// 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index 14b383204d6..bc6a22619cb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -15,6 +15,25 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertEqual(outcome, .mayHaveGoneThrough) } + func testShouldKeepAPendingPrivateRequestPending() { + let outcome = ContactRequestSubmissionOutcome.of( + PlatformWalletError.privateContactRequestPending("not yet read back from the store") + ) + XCTAssertEqual(outcome, .mayHaveGoneThrough) + } + + func testShouldShowAWalletPendingReplyOverAnEarlierError() { + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage(pending: true, error: "Accept failed: boom"), + ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + ) + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage(pending: false, error: "Accept failed: boom"), + "Accept failed: boom" + ) + XCTAssertNil(ContactRequestSubmissionOutcome.rowMessage(pending: false, error: nil)) + } + func testShouldOfferARetryForAnOrdinaryFailure() { let error = PlatformWalletError.shieldedBroadcastFailed("rejected by the relay") XCTAssertEqual( From e024423268253921002c167cdf1f4022e66932d9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 03:06:57 +0700 Subject: [PATCH 19/37] docs(platform-wallet): name the unread private store as a pending case of code 71 `ErrorPrivateContactRequestPending` (71) is now also returned when an identity's private store could not be read to its end before its first private send or accept since load: the wallet reads it first so that a reply paid for in an earlier process is adopted rather than paid for again. Nothing was paid, and a later private sync or a retry settles it. The FFI enum doc, the registry row and Swift's `privateContactRequestPending` doc say so. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 12 ++++++++---- .../PlatformWallet/PlatformWalletResult.swift | 8 +++++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 4b2c4501e83..e7b1c10ab1e 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -184,7 +184,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the host accepts with `platform_wallet_manager_accept_contact_request_replying_in_kind` (which picks the reply itself, so a host calling it does not route on 68) or `platform_wallet_manager_accept_private_contact_request`, or sends with `platform_wallet_manager_send_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer | | 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. Nothing new was paid; a later private contact request sync 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 | +| 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 | **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/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 7455c6d30be..269ed8ee62b 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -652,10 +652,14 @@ pub enum PlatformWalletFFIResultCode { /// Maps `PlatformWalletError::PrivateContactRequestPending`: a private /// contact request to that identity is in flight (another send or reply - /// reserved it), or was paid for and is not yet read back from the store. - /// Nothing new was paid. Do not submit it again: a later private contact - /// request sync (`platform_wallet_sync_private_contact_requests`) - /// settles it. + /// 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 load (the wallet reads it first, + /// so a reply paid for in an earlier process is adopted rather than paid + /// for again). Nothing new was paid. Do not submit it again: a later + /// private contact request sync + /// (`platform_wallet_sync_private_contact_requests`) or a retry settles + /// it. ErrorPrivateContactRequestPending = 71, /// The named thing does not exist. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 7e205db7fee..6374d5e1d85 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -633,9 +633,11 @@ public enum PlatformWalletError: LocalizedError { 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. Nothing new was paid; a later - /// `ManagedPlatformWallet.syncPrivateContactRequests` settles it. Pending, - /// not failed: do not submit it again. + /// 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) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition From 8a098b2fbcf6ad5d8763d3ace54b1174094dcd09 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 03:06:57 +0700 Subject: [PATCH 20/37] fix(swift-example-app): keep "Send privately" through Add Contact's collision accept With "Send privately" on and a request already received from the recipient, the collision alert's accept called the in-kind accept. A publicly received request then got a public `contactRequest` reply, which publishes this user as its owner and the contact as `toUserId`, against the chosen option and without saying so. The accept now follows the toggle, through the pure `CollisionAccept` helper: - On, it replies privately with `acceptPrivateContactRequest`, paid from the shielded balance. The Rust private accept takes a publicly received request too. The alert's button reads "Accept privately" and its message says the reply is private and paid from the shielded balance. - Off, it is the in-kind accept, as before. The accept is refused while the wallet reports a private reply to the contact pending, as Send is. Outcomes are read as elsewhere: an unconfirmed spend or code 71 stays pending. `ContactRequestOutcomesTests` pins both toggle states. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 37 +++++++++++----- .../DashPay/ContactRequestOutcomes.swift | 42 +++++++++++++++++++ .../ContactRequestOutcomesTests.swift | 19 +++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index c5d35021ecf..b42aa0ee45e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -159,14 +159,14 @@ 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", @@ -550,6 +550,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 is already pending in the wallet. + guard !recipientPending 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 @@ -564,14 +569,26 @@ struct AddContactView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - // 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 - ) + 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 { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 70e1e28e28c..6e5244eb481 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -38,6 +38,48 @@ enum ContactRequestSubmissionOutcome: Equatable { } } +/// 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, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index bc6a22619cb..6c4307860be 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -34,6 +34,25 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertNil(ContactRequestSubmissionOutcome.rowMessage(pending: false, error: nil)) } + 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( From 9640e70971a52fa605228b20326ced69193ae4e9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:52:00 +0700 Subject: [PATCH 21/37] fix(platform-wallet-ffi): keep a wallet removed before a private payment at NotFound `map_spend_result` sent `WalletRemovedBeforeSend` through its catch-all to `ErrorWalletOperation` (6), while every other export gives it the lifecycle refusal code `NotFound` (98). It now passes through the blanket conversion beside `PrivateContactRequestPending`, so a host can tell a removed wallet from an ordinary failure without reading the message. Co-Authored-By: Claude Opus 5.5 --- .../src/shielded_send.rs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index ceb21c93a5c..19b67d45942 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -622,8 +622,11 @@ 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. - Err(e @ PlatformWalletError::PrivateContactRequestPending(..)) => e.into(), + // 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. + Err(e @ PlatformWalletError::PrivateContactRequestPending(..)) + | Err(e @ PlatformWalletError::WalletRemovedBeforeSend(..)) => 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 @@ -3020,6 +3023,19 @@ mod tests { assert!(message_of(&result).contains("this request was not started")); } + /// A private payment refused because its wallet was removed first keeps + /// the lifecycle refusal code, not the generic operation failure. + #[test] + fn map_spend_result_keeps_a_wallet_removed_before_send_at_not_found() { + let removed: Result<(), PlatformWalletError> = Err( + PlatformWalletError::WalletRemovedBeforeSend("wallet removed".to_string()), + ); + let result = map_spend_result(removed, "private contact request"); + assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!(result.code as i32, 98); + assert!(message_of(&result).contains("wallet removed")); + } + /// A key-unavailable signer completion preserved under `Sdk` by the wallet /// layer must keep code 31 through `map_spend_result` (the shield from /// identity path) instead of flattening to `ErrorWalletOperation`; a From df84f924e42233fdd6e67299e97c6d44114bac4a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:52:00 +0700 Subject: [PATCH 22/37] feat(platform-wallet-ffi,swift-sdk): persist the private contact request scan state The wallet keeps where each identity reads DashPay's private contact request store from next, and its private contacts' shielded addresses, behind `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 14). This carries them across the FFI and stores them in SwiftData, so a relaunch resumes the private sync instead of reading the store from its start. FFI: three size-gated `PersistenceCallbacksExtension` slots, appended after the asset-lock operation id pair. The persist slot receives a round's read positions, addresses, address removals and the identities the round removed (whose scan state the host clears), after the contacts callback. The load slot returns a wallet's positions and addresses during the wallet-list load; they are applied to the identities that load restored, and a host error or a missing buffer fails the load. The capability is attested only with all three slots, the wallet-list restore callbacks and the declaration, and nothing is read from a host that does not attest it. Swift: two new models, `PersistentPrivateContactRequestsPosition` and `PersistentPrivateShieldedAddress`, in a new live schema V5; V4 is bound to its published snapshot with a lightweight V4 to V5 stage. The handler writes them inside the changeset round, purges them with the wallet, and declares the capability. Co-Authored-By: Claude Opus 5.5 --- .../src/contact_persistence.rs | 40 + .../rs-platform-wallet-ffi/src/manager.rs | 91 +- .../rs-platform-wallet-ffi/src/persistence.rs | 792 +++++++++++++++++- packages/swift-sdk/SCHEMA_RELEASES.md | 3 +- .../Persistence/DashModelContainer.swift | 41 +- ...istentPrivateContactRequestsPosition.swift | 28 + .../PersistentPrivateShieldedAddress.swift | 26 + .../PlatformWalletManager.swift | 8 + .../PlatformWalletPersistenceHandler.swift | 268 ++++++ .../DashLegacySchemaMigrationTests.swift | 15 +- .../DashModelMigrationTests.swift | 14 +- .../InvitationPersistenceTests.swift | 4 + ...vateContactScanStatePersistenceTests.swift | 187 +++++ packages/swift-sdk/schema-models.json | 4 +- 14 files changed, 1483 insertions(+), 38 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift diff --git a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs index 416e82870dc..eedd9996390 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs @@ -214,6 +214,46 @@ 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], +} + +/// 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. diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index dd0675fca9f..222ebdc2422 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; @@ -290,6 +292,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 +881,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 +1389,68 @@ mod tests { assert!(parsed.load_identity_balance_block_time.is_none()); } + #[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..f1b6636fce0 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,14 @@ 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, 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 +333,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 +550,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 +612,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 +636,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 +1794,25 @@ 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. + if wallet_restore + && 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 +3070,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 +3630,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 +6414,202 @@ 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, + }, + ) + .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, + }); + } + } + 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 +8045,389 @@ 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())); + } + + fn scan_state_persister( + host: &ScanStateHost, + declared: PersistenceCapabilities, + extensions: PersistenceExtensionCallbacks, + ) -> 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), + ..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], + }, + ); + 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], + }] + ); + 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], + }, + PrivateContactRequestsPositionFFI { + identity_id: [8; 32], + position: 99, + key_fingerprint: [4; 32], + }, + ]); + 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); + // 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, or wires only part of + /// the trio, 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], + }); + 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(), + ); + for persister in [&undeclared, &half_wired, &unwired] { + 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], + }, + ); + 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 +11018,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 +11110,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/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..9227c0c9d7b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift @@ -0,0 +1,28 @@ +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 + + public init(networkRaw: UInt32, walletId: Data, identityId: Data, + position: UInt64, keyFingerprint: Data) { + self.networkRaw = networkRaw + self.walletId = walletId + self.identityId = identityId + self.position = Int64(bitPattern: position) + self.keyFingerprint = keyFingerprint + } +} 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..d5c0519d897 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,133 @@ 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 + } + + /// 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 + } else { + backgroundContext.insert(PersistentPrivateContactRequestsPosition( + networkRaw: networkRaw, walletId: walletId, identityId: identityId, + position: row.position, keyFingerprint: row.keyFingerprint)) + } + } + 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) + }, + 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 +6785,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 +12067,119 @@ 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)) + }, + 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 row whose +/// stored bytes have the wrong length is skipped rather than handed back +/// on zeros. +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 } + let positions = stored.positions.filter { $0.identityId.count == 32 && $0.keyFingerprint.count == 32 } + let addresses = stored.addresses.filter { + $0.ownerId.count == 32 && $0.contactId.count == 32 && $0.address.count == 43 + } + 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) } + (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/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/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..0e001e2bd19 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift @@ -0,0 +1,187 @@ +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]) + + // 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) + } + + 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 + 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) + 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": [ { From 62169d368d1101123ad4a77b46fffd175826766e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:52:00 +0700 Subject: [PATCH 23/37] fix(swift-example-app): keep a failed pending read apart from nothing pending `privateRepliesPending` read a failed wallet read as "nothing pending", so the request list brought back Accept and Ignore, and Add Contact enabled Send, with nothing showing the pending request had cleared. The read now returns nil on a failure, and `PrivateRepliesPendingSnapshot` keeps the last successful snapshot and leaves those controls off for every contact until a read covering it succeeds. An unmanaged identity still reads as nothing pending. A pending private request (code 71), which the first public send or accept since load can now meet, gets its own outcome: nothing was sent, so the view shows it as pending and Add Contact offers "Sync private requests", after which Send comes back for the user to try again. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 83 +++++++++++++++---- .../DashPay/ContactRequestOutcomes.swift | 74 +++++++++++++++-- .../Views/DashPay/ContactRequestsView.swift | 29 ++++--- .../Views/DashPay/ContactsView.swift | 34 ++++++-- .../ContactRequestOutcomesTests.swift | 50 ++++++++++- 5 files changed, 224 insertions(+), 46 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index b42aa0ee45e..7067207135d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -64,9 +64,13 @@ struct AddContactView: View { /// 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; a private sync settles it. Read from the wallet, - /// so reopening this sheet keeps it. - @State private var recipientPending = false + /// 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 @@ -106,7 +110,14 @@ struct AddContactView: View { && resolvedRecipient != identity.identityId && !isSending && unconfirmedNotice == nil - && !recipientPending + && resolvedRecipient.map(recipientPending.allowsSubmission(to:)) == true + } + + /// Whether the footer shows the recipient's pending state: 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.isPending(recipient) || recipientPending.isUnavailable } var body: some View { @@ -413,9 +424,26 @@ struct AddContactView: View { .disabled(!canSend) .accessibilityIdentifier("dashpay.addContact.send") } footer: { - if recipientPending { - Text(ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage) + if let recipient = resolvedRecipient, recipientBlocked { + VStack(alignment: .leading, spacing: 6) { + Text(ContactRequestSubmissionOutcome.rowMessage( + pending: recipientPending.isPending(recipient), + unavailable: recipientPending.isUnavailable, + error: nil + ) ?? "") .accessibilityIdentifier("dashpay.addContact.pending") + Button { + syncPrivateRequests() + } label: { + if isSyncingPrivate { + ProgressView() + } else { + Text("Sync private requests") + } + } + .disabled(isSyncingPrivate) + .accessibilityIdentifier("dashpay.addContact.syncPrivate") + } } } } @@ -423,15 +451,31 @@ struct AddContactView: View { /// Re-read from the wallet whether a private request to the resolved /// recipient is pending. private func reloadRecipientPending() { - guard let recipient = resolvedRecipient, let wallet = try? requireWallet() else { - recipientPending = false - return + let contactIds = resolvedRecipient.map { [$0] } ?? [] + recipientPending.record( + privateRepliesPending( + wallet: try? requireWallet(), + identityId: identity.identityId, + contactIds: contactIds + ), + for: contactIds + ) + } + + /// 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() } - recipientPending = privateRepliesPending( - wallet: wallet, - identityId: identity.identityId, - contactIds: [recipient] - ).contains(recipient) } private func previewDisplayName( @@ -538,6 +582,10 @@ struct AddContactView: View { onSent(recipient, mode == .dpns ? selectedResult?.fullName : nil) kickDashPaySync(walletManager) unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + case .privateRequestPending: + // Nothing was sent: the footer shows the pending request + // and offers the private sync that settles it. + break case .failed(let message): errorMessage = message } @@ -550,8 +598,8 @@ 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 is already pending in the wallet. - guard !recipientPending else { return } + // 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) @@ -596,6 +644,9 @@ struct AddContactView: View { case .mayHaveGoneThrough: kickDashPaySync(walletManager) unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + case .privateRequestPending: + // Nothing was sent: the footer offers the private sync. + break case .failed(let message): errorMessage = "Accept failed: \(message)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 6e5244eb481..c37f2c28909 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -6,30 +6,43 @@ import SwiftDashSDK /// 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) is the same outcome: -/// nothing new was paid, and the next private sync settles it. Every other -/// error is an ordinary failure the user may retry. +/// 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. 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 /// 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." + /// The message a request row shows: the pending notice while the wallet - /// has a private reply to the contact pending, whatever error an earlier - /// attempt left, and that error otherwise. - static func rowMessage(pending: Bool, error: String?) -> String? { - pending ? mayHaveGoneThroughMessage : error + /// has a private request to the contact pending, the unavailable notice + /// while the wallet could not be read, whatever error an earlier attempt + /// left, and that error otherwise. + static func rowMessage(pending: Bool, unavailable: Bool = false, error: String?) -> String? { + 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, .privateContactRequestPending: + case .shieldedSpendUnconfirmed: return .mayHaveGoneThrough + case .privateContactRequestPending: + return .privateRequestPending default: break } @@ -38,6 +51,51 @@ enum ContactRequestSubmissionOutcome: Equatable { } } +/// 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. +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 + + /// 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 + } + + /// Whether the last successful read found a private request to + /// `contactId` pending. + func isPending(_ contactId: Data) -> Bool { + pending.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) && !pending.contains(contactId) + } +} + /// 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 2ccaa3ab012..a9b835e369c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -45,8 +45,10 @@ struct ContactRequestsView: View { /// 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. - @State private var walletPendingIds: Set = [] + /// 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? @@ -139,9 +141,10 @@ struct ContactRequestsView: View { avatarUrl: nil, createdAtMillis: row.createdAtMillis, isInFlight: inFlightIds.contains(row.contactIdentityId) - || walletPendingIds.contains(row.contactIdentityId), + || !walletPending.allowsSubmission(to: row.contactIdentityId), errorMessage: ContactRequestSubmissionOutcome.rowMessage( - pending: walletPendingIds.contains(row.contactIdentityId), + pending: walletPending.isPending(row.contactIdentityId), + unavailable: walletPending.isUnavailable, error: rowErrors[row.contactIdentityId] ), onAccept: { accept(contactId: row.contactIdentityId) }, @@ -220,11 +223,14 @@ struct ContactRequestsView: View { /// Re-read which incoming rows have a private reply pending, from the /// wallet. private func reloadPendingReplies() { - guard let wallet = try? requireWallet() else { return } - walletPendingIds = privateRepliesPending( - wallet: wallet, - identityId: identity.identityId, - contactIds: incomingPending.map(\.contactIdentityId) + let contactIds = incomingPending.map(\.contactIdentityId) + walletPending.record( + privateRepliesPending( + wallet: try? requireWallet(), + identityId: identity.identityId, + contactIds: contactIds + ), + for: contactIds ) } @@ -286,10 +292,11 @@ struct ContactRequestsView: View { kickDashPaySync(walletManager) } catch { switch ContactRequestSubmissionOutcome.of(error) { - case .mayHaveGoneThrough: + case .mayHaveGoneThrough, .privateRequestPending: // 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. + // and the row reads that; a sync (pull to refresh reads + // the private store) settles it. kickDashPaySync(walletManager) case .failed(let message): rowErrors[contactId] = "Accept failed: \(message)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index 3d7d9109f00..6a16d4e1c18 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -229,20 +229,36 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { /// 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 private send or reply to it, so the views show it as -/// pending and offer neither; a private sync settles it. Empty when the -/// identity is not managed. +/// 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, + wallet: ManagedPlatformWallet?, identityId: Data, contactIds: [Data] -) -> Set { - guard !contactIds.isEmpty, - let managed = try? wallet.managedIdentity(identityId: identityId) else { - return [] +) -> 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 } - return Set(contactIds.filter { (try? managed.privateReplyPending(contactId: $0)) == true }) } /// Read DashPay's private contact request store (protocol version 15) for diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index 6c4307860be..f414cf4f010 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -15,25 +15,71 @@ final class ContactRequestOutcomesTests: XCTestCase { 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, .mayHaveGoneThrough) + XCTAssertEqual(outcome, .privateRequestPending) } func testShouldShowAWalletPendingReplyOverAnEarlierError() { XCTAssertEqual( ContactRequestSubmissionOutcome.rowMessage(pending: true, error: "Accept failed: boom"), - ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + 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 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) From 342162b4a1d3cc302194937c009c1e1c64e9c5b9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:57:52 +0700 Subject: [PATCH 24/37] feat(swift-example-app): show the private contact request scan state in Storage Explorer `PersistentPrivateContactRequestsPosition` and `PersistentPrivateShieldedAddress` get a row, a count, a list view and a detail view, as `PersistentIdentityBalanceMetadata` does, so the explorer covers every model `DashModelContainer` registers again. Co-Authored-By: Claude Opus 5.5 --- .../Views/StorageExplorerView.swift | 16 +++++ .../Views/StorageModelListViews.swift | 62 +++++++++++++++++++ .../Views/StorageRecordDetailViews.swift | 44 +++++++++++++ 3 files changed, 122 insertions(+) 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..9425366cd32 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -86,6 +86,50 @@ 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)) + } + } + .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 { From 5166952033e70b84798ba7f48db64165c00aece8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:04:24 +0700 Subject: [PATCH 25/37] test(platform-wallet-ffi): pin the layouts of the private contact request FFI structs Compile-time size, alignment and field-offset guards, as the contact structs have: the scan-position row (72/8), the shielded-address row (107/1), `PrivateContactSyncReportFFI` (24/8) and `PrivateContactFFI` (46/1), on 64-bit targets. Co-Authored-By: Claude Opus 5.5 --- .../src/contact_persistence.rs | 25 +++++++++++++++ .../src/private_contact_request.rs | 32 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs index eedd9996390..ada1d0cdb79 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs @@ -308,6 +308,31 @@ 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] +// +// Total size = 72, alignment = 8 (from the u64). +const _: [u8; 72] = [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); + +// 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/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index c5ad4d911a4..069e24ab81d 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -97,6 +97,24 @@ pub struct PrivateContactSyncReportFFI { 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) } @@ -191,6 +209,20 @@ pub struct PrivateContactFFI { 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 { From 26bdbf7e50e4ec87e9e296b1fe134580f37d00d6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:04:24 +0700 Subject: [PATCH 26/37] fix(platform-wallet-ffi): require contact persistence for the private scan-state capability A read position vouches for the contact requests read before it. Without `on_persist_contacts_fn`, `FFIPersister::store` skips those requests and returns success, so the advanced position was stored without them and a restart skipped the entries that carried them. The capability now also needs the contacts callback; a host with the scan-state trio and the declaration but no contacts callback is not attested and has nothing read at load. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/persistence.rs | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index f1b6636fce0..0ba37f7e406 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -169,7 +169,8 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = /// `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, and the host declaring the bit. +/// 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 @@ -1795,8 +1796,13 @@ impl FFIPersister { 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 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 @@ -8180,10 +8186,35 @@ mod tests { 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 { @@ -8192,6 +8223,7 @@ mod tests { 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, @@ -8358,10 +8390,10 @@ mod tests { } } - /// A host that does not declare the capability, or wires only part of - /// the trio, 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. + /// 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(); @@ -8389,7 +8421,15 @@ mod tests { PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, PersistenceExtensionCallbacks::default(), ); - for persister in [&undeclared, &half_wired, &unwired] { + // 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)); From 17ffe98f9c740f61818188a40a641950387c5444 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:04:24 +0700 Subject: [PATCH 27/37] fix(platform-wallet-ffi): keep protocol-version refusals typed through map_spend_result The store read a private send or accept makes before paying can return `Sdk(ProtocolVersionNotActive)`, which the spend mapper flattened to `ErrorWalletOperation` (6). It now passes through the blanket conversion, like `PrivateContactPaymentsNotRestored`: a lasting refusal is 70, a transient one `ErrorUnknown`, as on every other path. Co-Authored-By: Claude Opus 5.5 --- .../src/shielded_send.rs | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 19b67d45942..1702ebe696c 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -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, as do unrestored + // private payments. 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 @@ -3023,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::ErrorPrivateContactRequestPending + ); + } + /// A private payment refused because its wallet was removed first keeps /// the lifecycle refusal code, not the generic operation failure. #[test] From 0f884bc027b57dae378b00c006b4448e49c7d41a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:04:24 +0700 Subject: [PATCH 28/37] fix(platform-wallet-ffi): report a seed mismatch from the private store by its DashPay code Each private sync pass, and the store read a send or accept makes first, now checks that the resolver's seed is the wallet's. A mismatch reached the host as `ErrorUnknown` (or `ErrorWalletOperation` through the spend mapper); it now takes `ErrorInvalidParameter`, the code every DashPay export and the seed check give it. Co-Authored-By: Claude Opus 5.5 --- .../src/private_contact_request.rs | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index 069e24ab81d..28c785b24c3 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -77,6 +77,22 @@ use crate::shielded_send::{ /// 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)] @@ -179,7 +195,10 @@ pub unsafe extern "C" fn platform_wallet_sync_private_contact_requests( .sync_private_contact_requests(&provider, max_pages as usize) .await }); - let report = unwrap_result_or_return!(result); + 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, @@ -482,7 +501,8 @@ unsafe fn send_private_contact_request_inner( unsafe { *out_request_handle = CONTACT_REQUEST_STORAGE.insert(request) }; PlatformWalletFFIResult::ok() } - Err(error) => map_spend_result(Err(error), "private contact request"), + Err(error) => seed_mismatch_result(error) + .unwrap_or_else(|error| map_spend_result(Err(error), "private contact request")), } } @@ -603,7 +623,8 @@ unsafe fn accept_private_contact_request_inner( unsafe { *out_established_handle = ESTABLISHED_CONTACT_STORAGE.insert(contact) }; PlatformWalletFFIResult::ok() } - Err(error) => map_spend_result(Err(error), "private contact request accept"), + Err(error) => seed_mismatch_result(error) + .unwrap_or_else(|error| map_spend_result(Err(error), "private contact request accept")), } } @@ -888,6 +909,10 @@ unsafe fn accept_contact_request_replying_in_kind_inner( { 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"); @@ -903,6 +928,25 @@ unsafe fn accept_contact_request_replying_in_kind_inner( 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 { From 7e93663c9540dc7306bee42c3a179a85030e10d8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:09:09 +0700 Subject: [PATCH 29/37] fix(swift-sdk): fail a scan-state load on a stored row that cannot cross the ABI The load shim filtered out stored rows of the wrong length and handed back the rest, so an address row of 42 bytes vanished while its identity's read position was restored, and the sync resumed past the entry that carried the address. Every row is now checked before either array is allocated, and one that cannot be marshalled fails the load as an unreadable store does. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.swift | 17 +++++----- ...vateContactScanStatePersistenceTests.swift | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index d5c0519d897..f25a88d4d02 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -12108,9 +12108,9 @@ private func persistPrivateContactScanStateCallback( } /// C shim for `on_load_private_contact_scan_state_fn`. Hands Rust two -/// arrays of flat rows, released by the free shim below. A row whose -/// stored bytes have the wrong length is skipped rather than handed back -/// on zeros. +/// 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?>?, @@ -12130,10 +12130,13 @@ private func loadPrivateContactScanStateCallback( do { stored = try handler.loadPrivateContactScanState(walletId: Data(bytes: walletId, count: 32)) } catch { return -1 } - let positions = stored.positions.filter { $0.identityId.count == 32 && $0.keyFingerprint.count == 32 } - let addresses = stored.addresses.filter { - $0.ownerId.count == 32 && $0.contactId.count == 32 && $0.address.count == 43 - } + // 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() { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift index 0e001e2bd19..6bea09bc492 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift @@ -112,6 +112,37 @@ final class PrivateContactScanStatePersistenceTests: XCTestCase { 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( From 490128af86ae2dadd8e564e1be115db2ebf72796 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:09:09 +0700 Subject: [PATCH 30/37] docs(swift-sdk): describe the restored private scan state and pending replies The private sync and `PrivateContactInfo` docs said the store cursors and shielded addresses live in memory only; they are restored where the store attests `privateContactRequestScanState`, with the full read as the fallback. `privateReplyPending` is restored too once the shielded store is configured after the load or the wallet binds. Co-Authored-By: Claude Opus 5.5 --- .../PrivateContactRequests.swift | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index 50522dbd86c..80e6bafd0af 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -19,8 +19,10 @@ import DashSDKFFI // `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; only the -/// contact's shielded address is kept in memory, rebuilt by +/// 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 @@ -84,7 +86,11 @@ extension ManagedIdentity { /// 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. Rust keeps it in memory only, so it is false after a relaunch. + /// 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 @@ -103,10 +109,13 @@ extension ManagedPlatformWallet { /// /// 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. The store cursors live in memory only, so 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. + /// 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 { From 289829a3944b74749f9dc8f88ca3a855c08499d7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:09:09 +0700 Subject: [PATCH 31/37] fix(swift-example-app): configure the shielded store right after the load A wallet that bound a shielded account in an earlier process has its public contact requests refused (code 71) until Rust restores its unconfirmed private payments, which configuring the shielded store does with no keys. The app configured it only when binding, which needs the mnemonic; it now configures the same per-network path right after `loadFromPersistor`, so the bind's own call is a no-op. Co-Authored-By: Claude Opus 5.5 --- .../Core/Services/ShieldedService.swift | 2 +- .../SwiftExampleApp/WalletManagerStore.swift | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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/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 { From edc9dd1f244800be996b7b7573b3b5a00f0c68ea Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:09:09 +0700 Subject: [PATCH 32/37] fix(swift-example-app): keep a code 71 refusal visible until a private sync Code 71 does not mean the wallet holds a reservation for the contact: it is also returned, with nothing reserved, when the private store could not be read to its end before the first send or accept since load. The views dropped the refusal and read the pending state, which then found nothing and brought Send and Accept back with no notice. `PrivateRepliesPendingSnapshot` now keeps the refused contact pending, with the private sync offered, until a private sync completes. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 11 ++++--- .../DashPay/ContactRequestOutcomes.swift | 30 ++++++++++++++++--- .../Views/DashPay/ContactRequestsView.swift | 15 ++++++++-- .../ContactRequestOutcomesTests.swift | 23 ++++++++++++++ 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 7067207135d..2956cc698ec 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -472,6 +472,8 @@ struct AddContactView: View { if let wallet = try? requireWallet() { if let notice = await syncPrivateContactRequests(wallet) { errorMessage = notice + } else { + recipientPending.recordPrivateSync() } } reloadRecipientPending() @@ -583,9 +585,10 @@ struct AddContactView: View { kickDashPaySync(walletManager) unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage case .privateRequestPending: - // Nothing was sent: the footer shows the pending request - // and offers the private sync that settles it. - break + // 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 .failed(let message): errorMessage = message } @@ -646,7 +649,7 @@ struct AddContactView: View { unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage case .privateRequestPending: // Nothing was sent: the footer offers the private sync. - break + recipientPending.recordRefusal(of: recipient) case .failed(let message): errorMessage = "Accept failed: \(message)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index c37f2c28909..88859c6328c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -57,6 +57,12 @@ enum ContactRequestSubmissionOutcome: Equatable { /// (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 a private sync completes. struct PrivateRepliesPendingSnapshot: Equatable { static let unavailableMessage = "Could not read whether a private request to " + "this contact is pending. Sync private requests to check again." @@ -67,6 +73,9 @@ struct PrivateRepliesPendingSnapshot: Equatable { 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) since + /// the last completed private sync. + private(set) var refused: Set = [] /// Record a read of `contactIds`: the pending ones, or nil when the read /// failed. @@ -80,10 +89,23 @@ struct PrivateRepliesPendingSnapshot: Equatable { available = true } - /// Whether the last successful read found a private request to - /// `contactId` pending. + /// 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 completed private sync, which settles every refusal: the + /// next read says what is still pending. + mutating func recordPrivateSync() { + refused.removeAll() + } + + /// 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 since the last completed private sync. func isPending(_ contactId: Data) -> Bool { - pending.contains(contactId) + pending.contains(contactId) || refused.contains(contactId) } /// Whether the last read failed. @@ -92,7 +114,7 @@ struct PrivateRepliesPendingSnapshot: Equatable { /// 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) && !pending.contains(contactId) + available && covered.contains(contactId) && !isPending(contactId) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index a9b835e369c..6709ba9a1c6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -186,6 +186,9 @@ struct ContactRequestsView: View { await attachOrStartSync(walletManager) if let wallet = try? requireWallet() { privateRefreshNotice = await syncPrivateContactRequests(wallet) + if privateRefreshNotice == nil { + walletPending.recordPrivateSync() + } } reloadPendingReplies() } @@ -292,11 +295,17 @@ struct ContactRequestsView: View { kickDashPaySync(walletManager) } catch { switch ContactRequestSubmissionOutcome.of(error) { - case .mayHaveGoneThrough, .privateRequestPending: + case .mayHaveGoneThrough: // As `SendViewModel` treats an unconfirmed spend: not a // failure to retry. The wallet keeps the reply pending, - // and the row reads that; a sync (pull to refresh reads - // the private store) settles it. + // 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 a private sync (pull to refresh) settles + // it. + walletPending.recordRefusal(of: contactId) kickDashPaySync(walletManager) case .failed(let message): rowErrors[contactId] = "Accept failed: \(message)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index f414cf4f010..01b787ac509 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -66,6 +66,29 @@ final class ContactRequestOutcomesTests: XCTestCase { 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 a private sync completes. + func testShouldKeepARefusedContactPendingAfterAnEmptyReadUntilAPrivateSync() { + 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) + + snapshot.recordPrivateSync() + snapshot.record([], for: [contact]) + XCTAssertFalse(snapshot.isPending(contact)) + XCTAssertTrue(snapshot.allowsSubmission(to: contact)) + } + /// A successful read that found nothing pending offers every contact it /// covered, and nothing it did not. func testShouldOfferEveryContactASuccessfulEmptyReadCovered() { From 0b28e29d7eb31d65a7bb7c1b64bc489f277707b2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:05:29 +0700 Subject: [PATCH 33/37] feat(platform-wallet-ffi): carry the unresolved private payment marker on the scan-state rows `PrivateContactRequestsPositionFFI` gains `private_payments_unresolved` (80 bytes, alignment 8): the store callback writes the wallet's marker and the load hands it back to `apply_private_contact_requests_position`, replacing the merge's set-on-load placeholder. Loaded set, the wallet holds the identity's public contact requests until its private payments are restored from the shielded store. Co-Authored-By: Claude Opus 5.5 --- .../src/contact_persistence.rs | 23 +++++++++++++++---- .../rs-platform-wallet-ffi/src/persistence.rs | 16 +++++++++---- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs index ada1d0cdb79..2c23c31439d 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs @@ -234,6 +234,11 @@ pub struct PrivateContactRequestsPositionFFI { /// 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 @@ -310,16 +315,24 @@ 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] +// 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 = 72, alignment = 8 (from the u64). -const _: [u8; 72] = [0u8; std::mem::size_of::()]; +// 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: // diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 6f05d0a8230..aa70789d325 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -6453,6 +6453,7 @@ impl PrivateContactScanStateRows { identity_id: identity_id.to_buffer(), position: position.position, key_fingerprint: position.key_fingerprint, + private_payments_unresolved: position.private_payments_unresolved, }, ) .collect(); @@ -6605,10 +6606,7 @@ fn load_private_contact_scan_state( managed.apply_private_contact_requests_position(PrivateContactRequestsPosition { position: row.position, key_fingerprint: row.key_fingerprint, - // The row does not carry the marker yet: restore it set, - // which holds the identity until its private payments are - // restored from the shielded store, rather than clear. - private_payments_unresolved: true, + private_payments_unresolved: row.private_payments_unresolved, }); } } @@ -8268,7 +8266,7 @@ mod tests { PrivateContactRequestsPosition { position: 12, key_fingerprint: [3; 32], - private_payments_unresolved: false, + private_payments_unresolved: true, }, ); contacts @@ -8298,6 +8296,7 @@ mod tests { identity_id: [7; 32], position: 12, key_fingerprint: [3; 32], + private_payments_unresolved: true, }] ); assert_eq!( @@ -8327,11 +8326,13 @@ mod tests { 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([ @@ -8359,6 +8360,10 @@ mod tests { .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]); @@ -8409,6 +8414,7 @@ mod tests { 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()); From f75b945facb3126784551f219f4ed7b4d1e00eac Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:23:58 +0700 Subject: [PATCH 34/37] feat(swift-sdk): store the unresolved private payment marker with the read position `PersistentPrivateContactRequestsPosition` (live schema V5, unreleased, so edited in place) gains `privatePaymentsUnresolved`, false by default. The handler stores the wallet's marker with the identity's read position and hands it back at load, so a relaunch holds the identity until its private payments are restored; Storage Explorer shows it. Co-Authored-By: Claude Opus 5.5 --- .../PersistentPrivateContactRequestsPosition.swift | 8 +++++++- .../PlatformWalletPersistenceHandler.swift | 13 ++++++++++--- .../Views/StorageRecordDetailViews.swift | 1 + .../PrivateContactScanStatePersistenceTests.swift | 14 ++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift index 9227c0c9d7b..789db14d2cb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift @@ -16,13 +16,19 @@ public final class PersistentPrivateContactRequestsPosition { /// 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) { + 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/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index f25a88d4d02..53040d476ed 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -5127,6 +5127,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { 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. @@ -5186,10 +5188,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { 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)) + position: row.position, keyFingerprint: row.keyFingerprint, + privatePaymentsUnresolved: row.privatePaymentsUnresolved)) } } for row in addresses { @@ -5237,7 +5241,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { positions.map { PrivateContactRequestsPositionRow( identityId: $0.identityId, position: UInt64(bitPattern: $0.position), - keyFingerprint: $0.keyFingerprint) + keyFingerprint: $0.keyFingerprint, + privatePaymentsUnresolved: $0.privatePaymentsUnresolved) }, addresses.map { PrivateShieldedAddressRow(ownerId: $0.ownerId, contactId: $0.contactId, address: $0.address) @@ -12092,7 +12097,8 @@ private func persistPrivateContactScanStateCallback( walletId: Data(bytes: walletId, count: 32), positions: positionRows.map { .init(identityId: dataFromTuple32($0.identity_id), position: $0.position, - keyFingerprint: dataFromTuple32($0.key_fingerprint)) + keyFingerprint: dataFromTuple32($0.key_fingerprint), + privatePaymentsUnresolved: $0.private_payments_unresolved) }, addresses: addressRows.map { var address = $0.address @@ -12144,6 +12150,7 @@ private func loadPrivateContactScanStateCallback( 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) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 9425366cd32..29431a3565a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -101,6 +101,7 @@ struct PrivateContactRequestsPositionStorageDetailView: View { 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") diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift index 6bea09bc492..e8405ee0b7f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift @@ -71,6 +71,18 @@ final class PrivateContactScanStatePersistenceTests: XCTestCase { 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]) @@ -169,6 +181,7 @@ final class PrivateContactScanStatePersistenceTests: XCTestCase { 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) } @@ -198,6 +211,7 @@ final class PrivateContactScanStatePersistenceTests: XCTestCase { 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 From afbe1d6a232b05069f208672e49a99f244e756d6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:23:58 +0700 Subject: [PATCH 35/37] feat(swift-sdk): type code 72 and read the wallet's private contact readiness `PlatformWalletError.privateContactPaymentsNotRestored` binds `ErrorPrivateContactPaymentsNotRestored` (72): the wallet's private payments from an earlier process are not restored yet, apart from a contact's pending request (71). `ManagedPlatformWallet.privateContactReadiness(identityId:)` binds `platform_wallet_get_private_contact_readiness`, so a host reads whether the payments are restored, whether the private store was read to its end, and whether a private payment may be unresolved. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- .../PlatformWallet/PlatformWalletResult.swift | 20 ++++++- .../PrivateContactRequests.swift | 52 +++++++++++++++++++ .../ErrorHandlingTests.swift | 27 ++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 6374d5e1d85..22963ef38a3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -282,6 +282,12 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// 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. @@ -421,6 +427,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { 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: @@ -639,6 +647,14 @@ public enum PlatformWalletError: LocalizedError { /// `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 @@ -866,7 +882,7 @@ public enum PlatformWalletError: LocalizedError { .assetLockTooManyInputs(let m), .privateContactRequestNeedsPrivateReply(let m), .shieldedBuildRequired(let m), .protocolVersionNotActive(let m), - .privateContactRequestPending(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), @@ -993,6 +1009,8 @@ public enum PlatformWalletError: LocalizedError { 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 index 80e6bafd0af..3a40d504f8a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -44,6 +44,44 @@ public struct PrivateContactInfo: Sendable, Equatable { } } +/// 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 @@ -102,6 +140,20 @@ extension ManagedIdentity { } 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 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index df94cd5b6f5..e5c8c605a6c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -521,6 +521,33 @@ final class ErrorHandlingTests: XCTestCase { 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 From 7405033aa555a198c86b91176b74372126b5744a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:23:58 +0700 Subject: [PATCH 36/37] fix(swift-example-app): show restoring private payments from the wallet's readiness The views cleared every code 71 refusal once a private sync finished, though a sync releases neither a contact's reservation nor the wallet's unrestored payments. They now render the wallet's own state: - code 72 shows "restoring private payments" for every contact, apart from a pending request (71), and only a readiness read that says restored lifts it; a private sync is not offered for it; - a code 71 refusal stays pending until the readiness says the private store was read to its end, after which the pending read decides; - the readiness is read with the pending state and again when the shielded account binds. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 48 +++++++---- .../DashPay/ContactRequestOutcomes.swift | 81 +++++++++++++++---- .../Views/DashPay/ContactRequestsView.swift | 21 +++-- .../Views/DashPay/ContactsView.swift | 11 +++ .../ContactRequestOutcomesTests.swift | 65 ++++++++++++++- 5 files changed, 188 insertions(+), 38 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 2956cc698ec..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 @@ -113,11 +116,13 @@ struct AddContactView: View { && resolvedRecipient.map(recipientPending.allowsSubmission(to:)) == true } - /// Whether the footer shows the recipient's pending state: a private - /// request to them is pending, or the wallet could not be read. + /// 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.isPending(recipient) || recipientPending.isUnavailable + return recipientPending.isRestoringPrivatePayments + || recipientPending.isPending(recipient) || recipientPending.isUnavailable } var body: some View { @@ -157,6 +162,7 @@ struct AddContactView: View { } .navigationTitle("Add Contact") .task(id: resolvedRecipient) { reloadRecipientPending() } + .onChange(of: shieldedService.isBound) { _, _ in reloadRecipientPending() } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -428,21 +434,26 @@ struct AddContactView: View { VStack(alignment: .leading, spacing: 6) { Text(ContactRequestSubmissionOutcome.rowMessage( pending: recipientPending.isPending(recipient), + restoring: recipientPending.isRestoringPrivatePayments, unavailable: recipientPending.isUnavailable, error: nil ) ?? "") .accessibilityIdentifier("dashpay.addContact.pending") - Button { - syncPrivateRequests() - } label: { - if isSyncingPrivate { - ProgressView() - } else { - Text("Sync private requests") + // 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") } - .disabled(isSyncingPrivate) - .accessibilityIdentifier("dashpay.addContact.syncPrivate") } } } @@ -452,14 +463,17 @@ struct AddContactView: View { /// recipient is pending. private func reloadRecipientPending() { let contactIds = resolvedRecipient.map { [$0] } ?? [] + let wallet = try? requireWallet() recipientPending.record( privateRepliesPending( - wallet: try? requireWallet(), + 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 @@ -472,8 +486,6 @@ struct AddContactView: View { if let wallet = try? requireWallet() { if let notice = await syncPrivateContactRequests(wallet) { errorMessage = notice - } else { - recipientPending.recordPrivateSync() } } reloadRecipientPending() @@ -589,6 +601,10 @@ struct AddContactView: View { // 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 } @@ -650,6 +666,8 @@ struct AddContactView: View { 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)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 88859c6328c..2289a163dbd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -9,13 +9,20 @@ import SwiftDashSDK /// 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. Every other error is an ordinary failure the user may retry. +/// 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) @@ -26,11 +33,18 @@ enum ContactRequestSubmissionOutcome: Equatable { + "pending. Sync private requests to settle it, then try again if it did not " + "go through." - /// The message a request row shows: the pending notice while the wallet - /// has a private request to the contact pending, the unavailable notice - /// while the wallet could not be read, whatever error an earlier attempt - /// left, and that error otherwise. - static func rowMessage(pending: Bool, unavailable: Bool = false, error: String?) -> String? { + 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 @@ -43,6 +57,8 @@ enum ContactRequestSubmissionOutcome: Equatable { return .mayHaveGoneThrough case .privateContactRequestPending: return .privateRequestPending + case .privateContactPaymentsNotRestored: + return .privatePaymentsNotRestored default: break } @@ -62,7 +78,15 @@ enum ContactRequestSubmissionOutcome: Equatable { /// 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 a private sync completes. +/// 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." @@ -73,9 +97,15 @@ struct PrivateRepliesPendingSnapshot: Equatable { 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) since - /// the last completed private sync. + /// 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. @@ -95,15 +125,37 @@ struct PrivateRepliesPendingSnapshot: Equatable { refused.insert(contactId) } - /// Record a completed private sync, which settles every refusal: the - /// next read says what is still pending. - mutating func recordPrivateSync() { - refused.removeAll() + /// 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 since the last completed private sync. + /// pending and the store has not been read to its end since. func isPending(_ contactId: Data) -> Bool { pending.contains(contactId) || refused.contains(contactId) } @@ -115,6 +167,7 @@ struct PrivateRepliesPendingSnapshot: Equatable { /// read covered it and found nothing pending. func allowsSubmission(to contactId: Data) -> Bool { available && covered.contains(contactId) && !isPending(contactId) + && !isRestoringPrivatePayments } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 6709ba9a1c6..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 @@ -144,6 +147,7 @@ struct ContactRequestsView: View { || !walletPending.allowsSubmission(to: row.contactIdentityId), errorMessage: ContactRequestSubmissionOutcome.rowMessage( pending: walletPending.isPending(row.contactIdentityId), + restoring: walletPending.isRestoringPrivatePayments, unavailable: walletPending.isUnavailable, error: rowErrors[row.contactIdentityId] ), @@ -186,13 +190,11 @@ struct ContactRequestsView: View { await attachOrStartSync(walletManager) if let wallet = try? requireWallet() { privateRefreshNotice = await syncPrivateContactRequests(wallet) - if privateRefreshNotice == nil { - walletPending.recordPrivateSync() - } } reloadPendingReplies() } .task { reloadPendingReplies() } + .onChange(of: shieldedService.isBound) { _, _ in reloadPendingReplies() } .onChange(of: rowSignature) { _, _ in pruneOverlays() reloadPendingReplies() @@ -227,14 +229,17 @@ struct ContactRequestsView: View { /// wallet. private func reloadPendingReplies() { let contactIds = incomingPending.map(\.contactIdentityId) + let wallet = try? requireWallet() walletPending.record( privateRepliesPending( - wallet: try? requireWallet(), + wallet: wallet, identityId: identity.identityId, contactIds: contactIds ), for: contactIds ) + walletPending.recordReadiness( + privateContactReadiness(wallet: wallet, identityId: identity.identityId)) } // MARK: - Overlay maintenance @@ -303,10 +308,14 @@ struct ContactRequestsView: View { 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 a private sync (pull to refresh) settles - // it. + // 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)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index 6a16d4e1c18..2a241112dcc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -261,6 +261,17 @@ func privateRepliesPending( } } +/// 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index 01b787ac509..5de5dd8e489 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -69,8 +69,8 @@ final class ContactRequestOutcomesTests: XCTestCase { /// 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 a private sync completes. - func testShouldKeepARefusedContactPendingAfterAnEmptyReadUntilAPrivateSync() { + /// 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) @@ -83,12 +83,71 @@ final class ContactRequestOutcomesTests: XCTestCase { pending: snapshot.isPending(contact), error: nil), ContactRequestSubmissionOutcome.privateRequestPendingMessage) - snapshot.recordPrivateSync() + // 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() { From 27f1a690628929a469b1e36b390835d9ca3fb43b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:23:58 +0700 Subject: [PATCH 37/37] feat(platform-wallet-ffi): compute a host's effective persistence capabilities without a manager `effective_persistence_capabilities` intersects a declaration with what a vtable and its extension structurally carry, as manager creation does, and releases the context. It lets a host adapter test which callbacks keep a capability; the JNI layer uses it to check its bridge overrides. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/manager.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 222ebdc2422..b93dda6a0b7 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -24,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; @@ -177,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 @@ -1389,6 +1416,44 @@ 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)]