From 3862f3b6bb1d595762cca08f2dcbe855564e56d0 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 15:07:22 -0500 Subject: [PATCH 1/3] feat(wallet): reserve DashPay payout addresses without Core funding --- .../rs-platform-wallet-ffi/src/dashpay.rs | 53 ++ .../src/wallet/identity/network/payments.rs | 469 ++++++++++++++---- .../src/wallet/persister.rs | 13 +- .../ManagedPlatformWallet.swift | 33 ++ 4 files changed, 470 insertions(+), 98 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index b3e7418b090..1692a07ff8e 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -589,6 +589,59 @@ pub unsafe extern "C" fn platform_wallet_fetch_sent_contact_requests( // Send payment // --------------------------------------------------------------------------- +/// Reserve a fresh contact Core address for a Platform or shielded withdrawal. +/// The address is durably consumed before success; callers must not reuse it. +/// This does not submit a payment or record payment history. +/// +/// # Safety +/// - Identity pointers must each reference 32 readable bytes. +/// - `core_signer_handle` must remain valid throughout this synchronous call. +/// - `out_address` must point to writable pointer storage. On success, free +/// the returned string with `platform_wallet_string_free`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_reserve_dashpay_payment_address( + wallet_handle: Handle, + from_identity_id: *const u8, + to_contact_identity_id: *const u8, + core_signer_handle: *mut MnemonicResolverHandle, + out_address: *mut *mut c_char, +) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); + check_ptr!(out_address); + *out_address = std::ptr::null_mut(); + let from_id = unwrap_result_or_return!(read_identifier(from_identity_id)); + let to_id = unwrap_result_or_return!(read_identifier(to_contact_identity_id)); + let signer_addr = core_signer_handle as usize; + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + let provider = resolver_contact_crypto_provider( + signer_addr as *mut MnemonicResolverHandle, + wallet.wallet_id(), + wallet.network(), + ); + block_on_worker(async move { + identity + .dashpay() + .reserve_payment_address(&from_id, &to_id, &provider) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let address = match result { + Ok(address) => address, + Err(e @ platform_wallet::PlatformWalletError::SeedMismatch { .. }) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e.to_string(), + ); + } + Err(e) => return e.into(), + }; + let c_str = unwrap_result_or_return!(std::ffi::CString::new(address.to_string())); + *out_address = c_str.into_raw(); + PlatformWalletFFIResult::ok() +} + /// Send a Dash payment from `from_identity_id` to `to_contact_identity_id`. /// /// The funding inputs are signed through the supplied diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 940b8da96ec..1a7e510aef6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1029,11 +1029,164 @@ async fn confirm_sent_payment_by_txid( } } +/// Select and consume from the one authoritative pool shared by Core sends +/// and externally funded contact payouts. Caller holds the manager write lock. +fn reserve_contact_payment_address( + wm: &mut WalletManager, + wallet_id: WalletId, + from_identity_id: &Identifier, + to_contact_id: &Identifier, +) -> Result<(dashcore::Address, crate::changeset::PlatformWalletChangeSet), PlatformWalletError> { + use key_wallet::account::account_collection::DashpayAccountKey; + let account_index = 0; + // Resolve the external account's xpub so we can derive addresses. + let contact_xpub = { + // Look up the external account in the *immutable* AccountCollection on + // `Wallet`. The ManagedAccountCollection only stores the managed state; + // the xpub lives on the immutable Account in `wallet.accounts`. + // For a watch-only external account we stored the contact's xpub directly + // as `account_xpub` on the Account struct — look it up via DashpayAccountKey. + let wallet = wm + .get_wallet(&wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))?; + wallet + .accounts + .dashpay_external_accounts + .get(&DashpayAccountKey { + index: account_index, + user_identity_id: from_identity_id.to_buffer(), + friend_identity_id: to_contact_id.to_buffer(), + }) + .map(|a| a.account_xpub) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "No DashpayExternalAccount found for contact {} — call \ + register_external_contact_account first", + to_contact_id + )) + })? + }; + + let info = wm + .get_wallet_info_mut(&wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))?; + + // Derive the next unused address from the external account's address pool. + let key = DashpayAccountKey { + index: account_index, + user_identity_id: from_identity_id.to_buffer(), + friend_identity_id: to_contact_id.to_buffer(), + }; + let external_account = info + .core_wallet + .accounts + .dashpay_external_accounts + .get_mut(&key) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "No managed DashpayExternalAccount found for contact {}", + to_contact_id + )) + })?; + + let payment_address = external_account + .next_address(Some(&contact_xpub), true) + .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; + + // `next_address`/`next_unused` only *selects* (and, when the + // gap-window is exhausted, generates) the next unused address — + // it does NOT flip its `used` flag (that lives solely on + // `AddressPool::mark_used`). DIP-15 per-payment rotation requires + // that once we commit this address to a payment it never be + // handed out again, so mark it used now, on the resident external + // account, before the snapshot below captures the pool. A + // `false` return would mean the address vanished from the pool + // between derivation and this call — a real invariant break, so + // fail loud rather than silently ship an un-rotated address. + if !external_account.mark_address_used(&payment_address) { + return Err(PlatformWalletError::TransactionBuild(format!( + "derived payment address {payment_address} is not in the external \ + account pool — cannot mark it used" + ))); + } + + // Snapshot the used-flag flip (and any gap-window extension) + // `next_address` + `mark_address_used` just applied to the + // external account's pool, as an owned changeset. The snapshot is + // captured here — while the pool is still borrowed under the + // guard — so the persisted state is exactly the flip just made, + // but the `persister.store` call itself is deferred until after + // this write guard is released (see below, before the broadcast). + // The host persistence callback must not run while the + // wallet-manager write lock is held: a slow host write would stall + // every other wallet accessor for its duration, and a host store + // that re-entered any manager API would deadlock the non-reentrant + // lock. + let external_account_type = key_wallet::account::AccountType::DashpayExternalAccount { + index: account_index, + user_identity_id: from_identity_id.to_buffer(), + friend_identity_id: to_contact_id.to_buffer(), + }; + let used_flip_changeset = crate::changeset::PlatformWalletChangeSet { + account_address_pools: crate::changeset::account_address_pool_entries( + external_account_type, + external_account.managed_account_type().address_pools(), + ), + ..Default::default() + }; + + Ok((payment_address, used_flip_changeset)) +} + // --------------------------------------------------------------------------- // Send payment to contact // --------------------------------------------------------------------------- impl DashPayView<'_, B> { + /// Reserve a fresh DIP-15 Core payout address without selecting Core funds. + /// + /// Use this after the user confirms a Platform or shielded withdrawal to + /// a contact. The address shares the Core send pool and its used flag is + /// persisted before it is returned. Once exposed, it is never released, + /// including when the withdrawal is rejected or its outcome is unknown. + /// Do not call this for previews: every successful call consumes an index. + /// This does not submit a payment or record payment history. + pub async fn reserve_payment_address( + &self, + from_identity_id: &Identifier, + to_contact_id: &Identifier, + provider: &C, + ) -> Result + where + C: crate::wallet::identity::network::contact_requests::ContactCryptoProvider + Sync, + { + self.drain_pending_contact_crypto_verified(provider, None) + .await?; + let _payment_guard = self.persister.lock_contact_payments().await; + let (address, changeset) = { + let mut wm = self.wallet_manager.write().await; + reserve_contact_payment_address( + &mut wm, + self.wallet_id, + from_identity_id, + to_contact_id, + )? + }; + // Host persistence may re-enter wallet APIs, so never invoke it under + // the manager guard. Failure must not expose an unpersisted address. + self.persister.store(changeset).map_err(|e| { + PlatformWalletError::Persistence(format!( + "failed to persist payment-address reservation: {e}" + )) + })?; + self.persister.flush().map_err(|e| { + PlatformWalletError::Persistence(format!( + "failed to flush payment-address reservation: {e}" + )) + })?; + Ok(address) + } + /// Send a Core payment to a DashPay contact. /// /// Derives the next payment address from the contact's `DashpayExternalAccount` @@ -1126,105 +1279,25 @@ impl DashPayView<'_, B> { // of being allowed to write first and fail second. self.drain_pending_contact_crypto_verified(provider, None) .await?; + let _payment_guard = self.persister.lock_contact_payments().await; let (payment_address, used_flip_changeset, tx, fee, funding_accounts, in_broadcast_pin) = { let mut wm = self.wallet_manager.write().await; - // Resolve the external account's xpub so we can derive addresses. - let contact_xpub = { - // Look up the external account in the *immutable* AccountCollection on - // `Wallet`. The ManagedAccountCollection only stores the managed state; - // the xpub lives on the immutable Account in `wallet.accounts`. - // For a watch-only external account we stored the contact's xpub directly - // as `account_xpub` on the Account struct — look it up via DashpayAccountKey. - let wallet = wm.get_wallet(&self.wallet_id).ok_or_else(|| { - PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)) - })?; - wallet - .accounts - .dashpay_external_accounts - .get(&DashpayAccountKey { - index: account_index, - user_identity_id: from_identity_id.to_buffer(), - friend_identity_id: to_contact_id.to_buffer(), - }) - .map(|a| a.account_xpub) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "No DashpayExternalAccount found for contact {} — call \ - register_external_contact_account first", - to_contact_id - )) - })? - }; - - let (wallet, info) = wm - .get_wallet_and_info_mut(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - - // Derive the next unused address from the external account's address pool. + let (payment_address, used_flip_changeset) = reserve_contact_payment_address( + &mut wm, + self.wallet_id, + from_identity_id, + to_contact_id, + )?; let key = DashpayAccountKey { index: account_index, user_identity_id: from_identity_id.to_buffer(), friend_identity_id: to_contact_id.to_buffer(), }; - let external_account = info - .core_wallet - .accounts - .dashpay_external_accounts - .get_mut(&key) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "No managed DashpayExternalAccount found for contact {}", - to_contact_id - )) - })?; - - let payment_address = external_account - .next_address(Some(&contact_xpub), true) - .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; - - // `next_address`/`next_unused` only *selects* (and, when the - // gap-window is exhausted, generates) the next unused address — - // it does NOT flip its `used` flag (that lives solely on - // `AddressPool::mark_used`). DIP-15 per-payment rotation requires - // that once we commit this address to a payment it never be - // handed out again, so mark it used now, on the resident external - // account, before the snapshot below captures the pool. A - // `false` return would mean the address vanished from the pool - // between derivation and this call — a real invariant break, so - // fail loud rather than silently ship an un-rotated address. - if !external_account.mark_address_used(&payment_address) { - return Err(PlatformWalletError::TransactionBuild(format!( - "derived payment address {payment_address} is not in the external \ - account pool — cannot mark it used" - ))); - } - - // Snapshot the used-flag flip (and any gap-window extension) - // `next_address` + `mark_address_used` just applied to the - // external account's pool, as an owned changeset. The snapshot is - // captured here — while the pool is still borrowed under the - // guard — so the persisted state is exactly the flip just made, - // but the `persister.store` call itself is deferred until after - // this write guard is released (see below, before the broadcast). - // The host persistence callback must not run while the - // wallet-manager write lock is held: a slow host write would stall - // every other wallet accessor for its duration, and a host store - // that re-entered any manager API would deadlock the non-reentrant - // lock. - let external_account_type = key_wallet::account::AccountType::DashpayExternalAccount { - index: account_index, - user_identity_id: from_identity_id.to_buffer(), - friend_identity_id: to_contact_id.to_buffer(), - }; - let used_flip_changeset = crate::changeset::PlatformWalletChangeSet { - account_address_pools: crate::changeset::account_address_pool_entries( - external_account_type, - external_account.managed_account_type().address_pools(), - ), - ..Default::default() - }; + let (wallet, info) = wm + .get_wallet_and_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; let current_height = info.core_wallet.synced_height(); @@ -1429,6 +1502,11 @@ impl DashPayView<'_, B> { ))); } + // Durability is complete. Other contact payments can reserve while + // this broadcast awaits the network. A definitive rejection takes + // the same gate again before snapshotting its address rollback. + drop(_payment_guard); + // --- 3. Broadcast the transaction, releasing the build's UTXO // reservation if the broadcast is definitively rejected pre-send. --- // Release across EVERY account that offered inputs, not just BIP44: @@ -1523,6 +1601,7 @@ impl DashPayView<'_, B> { // failure keeps the consumption: the transaction may still // have propagated, so the address must never be re-handed. if matches!(e, crate::broadcaster::BroadcastError::Rejected { .. }) { + let _payment_guard = self.persister.lock_contact_payments().await; let revert_changeset = { let mut wm = self.wallet_manager.write().await; wm.get_wallet_info_mut(&self.wallet_id).and_then(|info| { @@ -1720,6 +1799,7 @@ mod tests { /// `Some(n)` lets the next `n` `store` calls succeed and fails every /// later one until the budget is disarmed (`None` = always succeed). allow_stores_then_fail: Mutex>, + fail_flush: Mutex, /// `true` makes the enumeration answer `Ok(None)` — the shape of a /// backend that never wired wallet-scoped tx enumeration (Android). enumeration_unsupported: Mutex, @@ -1744,7 +1824,11 @@ mod tests { } } fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { - Ok(()) + if *self.fail_flush.lock().unwrap() { + Err(PersistenceError::backend("injected flush failure")) + } else { + Ok(()) + } } fn load(&self) -> Result { Ok(ClientStartState::default()) @@ -2129,8 +2213,8 @@ mod tests { txid.to_string() } - async fn install_external_account( - manager: &Arc>, + async fn install_external_account( + manager: &Arc>, wallet_id: WalletId, owner: Identifier, contact: Identifier, @@ -6218,6 +6302,174 @@ mod tests { // The full drain-then-send of a queued `RegisterExternal` is exercised // end-to-end by the live DashPay e2e flow. + #[tokio::test] + async fn reserve_payment_address_needs_no_core_funds_and_rotates_with_core_send() { + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + let (manager, persister, wallet_id, owner, contact) = + register_sender_and_external_account().await; + let wallet = manager.get_wallet(&wallet_id).await.unwrap(); + let iw = wallet.identity(); + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .unwrap() + .to_seed(""); + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + persister.stores.lock().unwrap().clear(); + let first = iw + .dashpay() + .reserve_payment_address(&owner, &contact, &provider) + .await + .unwrap(); + let second = iw + .dashpay() + .reserve_payment_address(&owner, &contact, &provider) + .await + .unwrap(); + assert_ne!(first, second); + { + let stores = persister.stores.lock().unwrap(); + for address in [&first, &second] { + assert!(stores + .iter() + .flat_map(|(_, cs)| &cs.account_address_pools) + .flat_map(|pool| &pool.addresses) + .any(|info| &info.address == address && info.is_used())); + } + } + // A subsequent transparent send must draw a third address, not reuse + // either durably exposed withdrawal destination. + fund_bip44_account_0(&manager, wallet_id, 0xC5, 120_000).await; + let signer = SeedSigner::new(seed, Network::Testnet); + with_accepting_broadcaster(iw) + .dashpay() + .send_payment(&owner, &contact, 50_000, None, &signer, &provider) + .await + .unwrap(); + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).unwrap(); + let key = DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + let pools = info.core_wallet.accounts.dashpay_external_accounts[&key] + .managed_account_type() + .address_pools(); + assert_eq!(pools[0].used_indices.len(), 3); + } + + #[tokio::test] + async fn reserve_payment_address_survives_concurrent_core_rejection() { + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + let (manager, persister, wallet_id, owner, contact) = + register_sender_and_external_account().await; + let wallet = manager.get_wallet(&wallet_id).await.unwrap(); + let iw = wallet.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xC6, 120_000).await; + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .unwrap() + .to_seed(""); + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + let signer = SeedSigner::new(seed, Network::Testnet); + let entered = Arc::new(tokio::sync::Barrier::new(2)); + let release = Arc::new(tokio::sync::Barrier::new(2)); + let gated = + with_gated_rejecting_broadcaster(iw, Arc::clone(&entered), Arc::clone(&release)); + let send = async { + gated + .dashpay() + .send_payment(&owner, &contact, 50_000, None, &signer, &provider) + .await + }; + let reserve = async { + entered.wait().await; + let address = iw + .dashpay() + .reserve_payment_address(&owner, &contact, &provider) + .await + .unwrap(); + release.wait().await; + address + }; + let (send_result, reserved) = + tokio::time::timeout(std::time::Duration::from_secs(10), async { + tokio::join!(send, reserve) + }) + .await + .expect("reservation must not wait for the network broadcast"); + assert!(matches!( + send_result, + Err(PlatformWalletError::TransactionBroadcast(_)) + )); + let stores = persister.stores.lock().unwrap(); + let latest = stores + .iter() + .rev() + .flat_map(|(_, cs)| &cs.account_address_pools) + .find(|pool| { + matches!( + pool.account_type, + key_wallet::account::AccountType::DashpayExternalAccount { .. } + ) + }) + .unwrap(); + assert!(latest.addresses.iter().any(|info| info.address == reserved && info.is_used()), + "the rejected Core send's later rollback snapshot must preserve the withdrawal reservation"); + } + + #[tokio::test] + async fn reserve_payment_address_does_not_expose_on_persistence_failure() { + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + let persister = Arc::new(RecordStorePersister::default()); + let (manager, wallet_id) = make_wallet_with(Arc::clone(&persister)).await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let addresses = install_external_account(&manager, wallet_id, owner, contact).await; + let wallet = manager.get_wallet(&wallet_id).await.unwrap(); + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .unwrap() + .to_seed(""); + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + *persister.allow_stores_then_fail.lock().unwrap() = Some(0); + assert!(matches!( + wallet + .identity() + .dashpay() + .reserve_payment_address(&owner, &contact, &provider) + .await, + Err(PlatformWalletError::Persistence(_)) + )); + *persister.allow_stores_then_fail.lock().unwrap() = None; + *persister.fail_flush.lock().unwrap() = true; + assert!(matches!( + wallet + .identity() + .dashpay() + .reserve_payment_address(&owner, &contact, &provider) + .await, + Err(PlatformWalletError::Persistence(_)) + )); + *persister.fail_flush.lock().unwrap() = false; + let next = wallet + .identity() + .dashpay() + .reserve_payment_address(&owner, &contact, &provider) + .await + .unwrap(); + assert_ne!( + next, addresses[0], + "failed store leaves conservative in-memory consumption" + ); + assert!( + wallet + .identity() + .dashpay() + .reserve_payment_address(&contact, &owner, &provider) + .await + .is_err(), + "the owner/contact scope cannot be reversed" + ); + } + /// `send_payment` drains the deferred contact-crypto queue before it /// resolves the external account. A `RegisterExternal` op can't be built /// from under the single-shot mock fetch (see the module comment above), so @@ -6225,8 +6477,17 @@ mod tests { /// `SeedCryptoProvider` completes with no fetch: after a (still-failing) /// `send_payment`, that queued op is drained. Without the send-path drain /// the op stays queued. + #[tokio::test] + async fn reserve_payment_address_runs_verified_contact_crypto_drain() { + assert_payment_runs_pending_contact_crypto_drain(true).await; + } + #[tokio::test] async fn send_payment_runs_pending_contact_crypto_drain() { + assert_payment_runs_pending_contact_crypto_drain(false).await; + } + + async fn assert_payment_runs_pending_contact_crypto_drain(reserve_only: bool) { use crate::changeset::{PendingContactCrypto, PendingContactCryptoOp}; use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; use key_wallet::account::account_collection::DashpayAccountKey; @@ -6277,10 +6538,24 @@ mod tests { // The send fails (no external account for `pay_contact`), but the drain // it runs first must have completed the queued RegisterReceiving op. - let result = iw - .dashpay() - .send_payment(&owner, &pay_contact, 10_000, None, &signer, &provider) - .await; + let result = if reserve_only { + let foreign = SeedCryptoProvider::from_seed([42; 64], Network::Testnet); + assert!(matches!( + iw.dashpay() + .reserve_payment_address(&owner, &pay_contact, &foreign) + .await, + Err(PlatformWalletError::SeedMismatch { .. }) + )); + iw.dashpay() + .reserve_payment_address(&owner, &pay_contact, &provider) + .await + .map(|_| ()) + } else { + iw.dashpay() + .send_payment(&owner, &pay_contact, 10_000, None, &signer, &provider) + .await + .map(|_| ()) + }; assert!( result.is_err(), "the send must still fail for an unbuilt external account" diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index fec0ce4638f..0266f1557b6 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -55,11 +55,22 @@ impl Drop for TransientMissTally { pub struct WalletPersister { wallet_id: WalletId, inner: Arc, + contact_payment_gate: Arc>, } impl WalletPersister { pub fn new(wallet_id: WalletId, inner: Arc) -> Self { - Self { wallet_id, inner } + Self { + wallet_id, + inner, + contact_payment_gate: Arc::new(tokio::sync::Mutex::new(())), + } + } + + /// Keep contact-pool snapshots in reservation order across Core and + /// withdrawal sends, including a Core broadcast rejection's pool rollback. + pub(crate) async fn lock_contact_payments(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.contact_payment_gate.lock().await } pub(crate) fn store(&self, changeset: PlatformWalletChangeSet) -> Result<(), PersistenceError> { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 8664e1dfb90..a6c90ea9e49 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2505,6 +2505,39 @@ extension ManagedPlatformWallet { }.value } + /// Reserve a fresh Core payout address for a DashPay contact without + /// requiring transparent funds. Call only after confirming a withdrawal: + /// each successful call permanently consumes an address shared with Core + /// sends. The SDK persists the reservation before returning it. + /// This does not submit a payment or record its history. + public func reserveDashPayPaymentAddress( + fromIdentityId: Identifier, + toContactIdentityId: Identifier + ) async throws -> String { + let handle = self.handle + let fromBytes = fromIdentityId.withFFIBytes { Array(UnsafeBufferPointer(start: $0, count: 32)) } + let toBytes = toContactIdentityId.withFFIBytes { Array(UnsafeBufferPointer(start: $0, count: 32)) } + let resolver = MnemonicResolver() + return try await Task.detached(priority: .userInitiated) { () -> String in + var address: UnsafeMutablePointer? + let result = withExtendedLifetime(resolver) { + fromBytes.withUnsafeBufferPointer { from in + toBytes.withUnsafeBufferPointer { to in + platform_wallet_reserve_dashpay_payment_address( + handle, from.baseAddress!, to.baseAddress!, resolver.handle, &address + ) + } + } + } + try result.check() + guard let address else { + throw PlatformWalletError.invalidParameter("Missing reserved DashPay address") + } + defer { platform_wallet_string_free(address) } + return String(cString: address) + }.value + } + /// Send a Dash payment to an established DashPay contact. /// `amountDuffs` is in duffs (1 DASH = 100_000_000 duffs). /// Returns the 32-byte transaction id plus the exact network fee From cadb566aa3ceb492b2b3da4f849f44f5e18daff9 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 17 Sep 2026 10:43:13 -0500 Subject: [PATCH 2/3] test(platform-wallet): pin the contact-payment gate's snapshot ordering Pool snapshots are whole-pool and last-write-wins on the host, so two reservations interleaving as A: snapshot, B: snapshot, B: store, A: store would leave B's address unused in the persisted truth and a relaunch would re-hand it. The contact-payment gate holds each reservation from snapshot through store to forbid that ordering, but the existing tests ran on a current-thread runtime with synchronous persistence callbacks, so the snapshot/store sequences never overlapped and removing the gate acquisition left every test green. Add a multithreaded test whose persister parks the first external-pool store inside the host callback, after the snapshot is captured. A competing reservation must not complete while that store is parked, nothing may reach the host in the meantime, and the last persisted snapshot must carry both addresses. Waits are bounded so a regression fails instead of wedging the runtime. Dropping the gate acquisition in reserve_payment_address makes the competing reservation complete while the first is parked and fails the first assertion. Also adopt the one-argument Mnemonic::from_phrase from the rebase base. Co-Authored-By: Claude Fable 5.1 --- .../src/wallet/identity/network/payments.rs | 185 +++++++++++++++++- 1 file changed, 176 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 1a7e510aef6..75d81416a03 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -6309,9 +6309,7 @@ mod tests { register_sender_and_external_account().await; let wallet = manager.get_wallet(&wallet_id).await.unwrap(); let iw = wallet.identity(); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .unwrap() - .to_seed(""); + let seed = Mnemonic::from_phrase(TEST_MNEMONIC).unwrap().to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); persister.stores.lock().unwrap().clear(); let first = iw @@ -6365,9 +6363,7 @@ mod tests { let wallet = manager.get_wallet(&wallet_id).await.unwrap(); let iw = wallet.identity(); fund_bip44_account_0(&manager, wallet_id, 0xC6, 120_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .unwrap() - .to_seed(""); + let seed = Mnemonic::from_phrase(TEST_MNEMONIC).unwrap().to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); let signer = SeedSigner::new(seed, Network::Testnet); let entered = Arc::new(tokio::sync::Barrier::new(2)); @@ -6425,9 +6421,7 @@ mod tests { let contact = Identifier::from([0xBB; 32]); let addresses = install_external_account(&manager, wallet_id, owner, contact).await; let wallet = manager.get_wallet(&wallet_id).await.unwrap(); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .unwrap() - .to_seed(""); + let seed = Mnemonic::from_phrase(TEST_MNEMONIC).unwrap().to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); *persister.allow_stores_then_fail.lock().unwrap() = Some(0); assert!(matches!( @@ -6470,6 +6464,179 @@ mod tests { ); } + /// Persister that parks the FIRST external-pool store inside `store`, + /// after the caller has captured its whole-pool snapshot, until the + /// test releases it. Every store is recorded in arrival order so the + /// test can assert which snapshot reached the host last. Both waits + /// are bounded so a regression fails the test instead of wedging the + /// runtime on a worker thread parked inside a sync store. + struct PausingPoolPersister { + stores: Mutex>, + parked_once: std::sync::atomic::AtomicBool, + entered: (Mutex, std::sync::Condvar), + release: (Mutex, std::sync::Condvar), + } + + impl PausingPoolPersister { + const WAIT: std::time::Duration = std::time::Duration::from_secs(5); + + fn new() -> Self { + Self { + stores: Mutex::new(Vec::new()), + parked_once: std::sync::atomic::AtomicBool::new(false), + entered: (Mutex::new(false), std::sync::Condvar::new()), + release: (Mutex::new(false), std::sync::Condvar::new()), + } + } + + fn signal(flag: &(Mutex, std::sync::Condvar)) { + *flag.0.lock().unwrap() = true; + flag.1.notify_all(); + } + + /// Block until `flag` is raised; `false` if `WAIT` elapsed first. + fn wait_for(flag: &(Mutex, std::sync::Condvar)) -> bool { + let guard = flag.0.lock().unwrap(); + let (guard, _) = flag + .1 + .wait_timeout_while(guard, Self::WAIT, |raised| !*raised) + .unwrap(); + *guard + } + + fn external_pool_snapshots(&self) -> Vec> { + self.stores + .lock() + .unwrap() + .iter() + .flat_map(|cs| &cs.account_address_pools) + .filter(|pool| { + matches!( + pool.account_type, + key_wallet::account::AccountType::DashpayExternalAccount { .. } + ) + }) + .map(|pool| pool.addresses.clone()) + .collect() + } + } + + impl PlatformWalletPersistence for PausingPoolPersister { + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + let is_external_pool = changeset.account_address_pools.iter().any(|pool| { + matches!( + pool.account_type, + key_wallet::account::AccountType::DashpayExternalAccount { .. } + ) + }); + if is_external_pool + && !self + .parked_once + .swap(true, std::sync::atomic::Ordering::SeqCst) + { + // The snapshot is already in `changeset`; park before it + // is recorded, exactly where a slow host write would sit. + Self::signal(&self.entered); + Self::wait_for(&self.release); + } + self.stores.lock().unwrap().push(changeset); + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + /// Pool snapshots are whole-pool and last-write-wins on the host. Two + /// reservations that interleave as + /// `A: snapshot → B: snapshot → B: store → A: store` leave A's older + /// snapshot (B's address still unused) as the persisted truth, and a + /// relaunch re-hands B's address. The contact-payment gate holds each + /// reservation from snapshot through store, so B cannot even snapshot + /// until A's store has returned. This drives that interleaving on a + /// real multithreaded runtime with A parked inside the host store; + /// removing the gate from `reserve_payment_address` lets B complete + /// while A is parked and fails the first assertion. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reserve_payment_address_cannot_persist_ahead_of_a_paused_reservation() { + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + let persister = Arc::new(PausingPoolPersister::new()); + let (manager, wallet_id) = make_wallet_with(Arc::clone(&persister)).await; + let owner = Identifier::from([0xAC; 32]); + let contact = Identifier::from([0xBD; 32]); + install_external_account(&manager, wallet_id, owner, contact).await; + let wallet = manager.get_wallet(&wallet_id).await.unwrap(); + let seed = Mnemonic::from_phrase(TEST_MNEMONIC).unwrap().to_seed(""); + let reserve = |wallet: Arc| { + tokio::spawn(async move { + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + wallet + .identity() + .dashpay() + .reserve_payment_address(&owner, &contact, &provider) + .await + }) + }; + + let first = reserve(Arc::clone(&wallet)); + // The first reservation has captured its snapshot and is parked + // inside the host store, gate still held. + let entered = Arc::clone(&persister); + assert!( + tokio::task::spawn_blocking(move || PausingPoolPersister::wait_for(&entered.entered)) + .await + .unwrap(), + "the first reservation never reached the host store" + ); + + let mut second = reserve(Arc::clone(&wallet)); + let raced = tokio::time::timeout(std::time::Duration::from_millis(500), &mut second).await; + let leaked = persister.external_pool_snapshots(); + // Unpark the first store before any assertion so a failure + // reports instead of wedging runtime shutdown on the parked worker. + PausingPoolPersister::signal(&persister.release); + assert!( + raced.is_err(), + "a competing reservation completed while the first one's store was still \ + parked, so its snapshot can be overwritten by the stale one: {raced:?}" + ); + assert!( + leaked.is_empty(), + "nothing may reach the host while the first store is parked" + ); + + let first = first.await.unwrap().unwrap(); + let second = second.await.unwrap().unwrap(); + assert_ne!(first, second); + + let snapshots = persister.external_pool_snapshots(); + assert_eq!( + snapshots.len(), + 2, + "one whole-pool snapshot per reservation" + ); + let used = |snapshot: &[key_wallet::AddressInfo]| { + snapshot + .iter() + .filter(|info| info.is_used()) + .map(|info| info.address.clone()) + .collect::>() + }; + assert_eq!(used(&snapshots[0]), vec![first.clone()]); + let latest = used(&snapshots[1]); + assert!( + latest.contains(&first) && latest.contains(&second), + "the last persisted snapshot must carry both reservations, got {latest:?}" + ); + } + /// `send_payment` drains the deferred contact-crypto queue before it /// resolves the external account. A `RegisterExternal` op can't be built /// from under the single-shot mock fetch (see the module comment above), so From 19e0b600cceaae786c14bdb9a2aef7ec1f2b59b3 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 17 Sep 2026 10:46:51 -0500 Subject: [PATCH 3/3] refactor(platform-wallet-ffi): reserve the payout address outside the registry guard Look the identity up under the wallet registry guard and run the reservation on the worker outside it, the shape the base adopted for platform_wallet_pending_contact_crypto_count. The reservation waits on the wallet-manager write lock, the contact-payment gate, and the host store and flush, and a registry read guard held across those stalls platform_wallet_destroy and, through parking_lot's writer preference, every other registry reader. Co-Authored-By: Claude Fable 5.1 --- .../rs-platform-wallet-ffi/src/dashpay.rs | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 1692a07ff8e..7d865f124c4 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -612,21 +612,35 @@ pub unsafe extern "C" fn platform_wallet_reserve_dashpay_payment_address( let from_id = unwrap_result_or_return!(read_identifier(from_identity_id)); let to_id = unwrap_result_or_return!(read_identifier(to_contact_identity_id)); let signer_addr = core_signer_handle as usize; + // Look the identity up under the registry guard, but wait outside it: + // the reservation waits on the manager write lock, the contact-payment + // gate, and the host store + flush, and a registry read guard held + // across those 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| { - let identity = wallet.identity().clone(); - let provider = resolver_contact_crypto_provider( - signer_addr as *mut MnemonicResolverHandle, + ( + wallet.identity().clone(), wallet.wallet_id(), wallet.network(), - ); - block_on_worker(async move { - identity - .dashpay() - .reserve_payment_address(&from_id, &to_id, &provider) - .await - }) + ) + }); + let (identity, wallet_id, network) = unwrap_option_or_return!(option); + // SAFETY: `signer_addr` came from `core_signer_handle`, which the caller + // pins alive for the duration of this synchronous call; the provider is + // dropped when the worker task completes, before this call returns. + let provider = unsafe { + resolver_contact_crypto_provider( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + let result = block_on_worker(async move { + identity + .dashpay() + .reserve_payment_address(&from_id, &to_id, &provider) + .await }); - let result = unwrap_option_or_return!(option); let address = match result { Ok(address) => address, Err(e @ platform_wallet::PlatformWalletError::SeedMismatch { .. }) => {