diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index b3e7418b090..7d865f124c4 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -589,6 +589,73 @@ 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; + // 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| { + ( + wallet.identity().clone(), + wallet.wallet_id(), + wallet.network(), + ) + }); + 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 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..75d81416a03 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,341 @@ 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).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).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).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" + ); + } + + /// 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 @@ -6225,8 +6644,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 +6705,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