diff --git a/packages/rs-platform-wallet-ffi/src/core_address_types.rs b/packages/rs-platform-wallet-ffi/src/core_address_types.rs index 3aa930abf02..45abb6a2b70 100644 --- a/packages/rs-platform-wallet-ffi/src/core_address_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_address_types.rs @@ -76,14 +76,14 @@ pub struct CoreAddressEntryFFI { pub pool_type_tag: u8, /// Derivation index within this pool. pub address_index: u32, - /// Whether funds had been seen at this address at emit time, i.e. - /// `AddressInfo.state == AddressState::Used`. + /// Whether the address must not be handed out again: funds were seen + /// at it (`AddressState::Used`), or it is reserved + /// (`AddressState::Reserved`). /// - /// This schema has no slot for a reservation, so an - /// `AddressState::Reserved` entry necessarily flattens to `false` - /// and reloads as `Available`. Nothing in platform reserves - /// addresses today, so that lossy case is unreachable; adding a - /// reserving caller requires a schema decision here first (see + /// This schema has no slot for a reservation, and the row is a reuse + /// guard, so a reservation is stored as `true` and reloads as `Used`. + /// Releasing it stores `false` again. The one reserving caller is the + /// two-step DashPay contact payment (see /// `persistence::build_core_address_entry_ffi`). pub is_used: bool, /// Cached balance in duffs from `AddressInfo.balance`. diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index d1f1edc79cf..edd24759bbc 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -7,7 +7,7 @@ use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; use platform_wallet::PlatformWalletError; use std::os::raw::c_char; -fn classify_broadcast_result( +pub(crate) fn classify_broadcast_result( result: Result, local_txid: dashcore::Txid, ) -> (Option, PlatformWalletFFIResult) { diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 94960f1378f..858ee5c84cd 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -39,10 +39,11 @@ use platform_wallet::ContactRequest; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; use crate::contact_request::CONTACT_REQUEST_STORAGE; +use crate::core_wallet::classify_broadcast_result; 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, runtime}; use crate::types::*; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; @@ -771,14 +772,308 @@ pub unsafe extern "C" fn platform_wallet_send_dashpay_payment( if !out_fee_duffs.is_null() { unsafe { *out_fee_duffs = fee_duffs }; } - use dashcore::hashes::Hash; - let bytes = txid.to_raw_hash().to_byte_array(); + unsafe { *out_txid = txid_bytes(&txid) }; + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// Prepared (two-step) payment +// --------------------------------------------------------------------------- + +/// A prepared contact payment together with the wallet it was prepared on, +/// held in `PREPARED_DASHPAY_PAYMENT_STORAGE` between prepare and +/// send / abandon. Keeping the wallet lets `free` release the inputs without +/// a wallet handle. +pub struct FFIPreparedDashPayPayment { + pub(crate) wallet: std::sync::Arc, + pub(crate) payment: + platform_wallet::PreparedDashPayPayment, +} + +/// Abandon `prepared` through the wallet it was prepared on. +fn abandon_prepared(prepared: FFIPreparedDashPayPayment) { + let FFIPreparedDashPayPayment { wallet, payment } = prepared; + block_on_worker(async move { + wallet + .identity() + .dashpay() + .abandon_prepared_payment(payment) + .await + }); +} + +/// Sign a Dash payment to an established contact WITHOUT broadcasting it, +/// so the host can show the exact network fee and total before the user +/// confirms. +/// +/// Same funding, signing and seed-binding gate as +/// [`platform_wallet_send_dashpay_payment`]. On success `*out_payment` holds +/// a handle the caller must pass to exactly one of +/// [`platform_wallet_send_prepared_dashpay_payment`], +/// [`platform_wallet_abandon_prepared_dashpay_payment`] or +/// [`platform_wallet_prepared_dashpay_payment_free`]; until then the funding +/// inputs stay reserved. The contact's address is not claimed until the send, +/// so dropping the payment leaves no address state to undo. +/// +/// A handle held while the wallet processes `RESERVATION_MAX_AGE_BLOCKS` +/// blocks is refused at send time with `ErrorStaleReservationToken` (34), +/// nothing sent, inputs released: prepare again. +/// +/// # Safety +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`, pinned by the caller for this call only. +/// - `out_payment`, `out_fee_duffs` and `out_total_duffs` must be valid +/// writable pointers, and `out_txid` must point to 32 writable bytes; they +/// are written only on success. The txid is the signed transaction's, the +/// one the send broadcasts. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_prepare_dashpay_payment( + wallet_handle: Handle, + from_identity_id: *const u8, + to_contact_identity_id: *const u8, + amount_duffs: u64, + memo: *const c_char, + core_signer_handle: *mut MnemonicResolverHandle, + out_payment: *mut Handle, + out_fee_duffs: *mut u64, + out_total_duffs: *mut u64, + out_txid: *mut [u8; 32], +) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); + check_ptr!(out_payment); + check_ptr!(out_fee_duffs); + check_ptr!(out_total_duffs); + check_ptr!(out_txid); + unsafe { *out_payment = NULL_HANDLE }; + + let from_id = unwrap_result_or_return!(unsafe { read_identifier(from_identity_id) }); + let to_id = unwrap_result_or_return!(unsafe { read_identifier(to_contact_identity_id) }); + let memo_str: Option = if memo.is_null() { + None + } else { + Some(unwrap_result_or_return!(unsafe { CStr::from_ptr(memo) }.to_str()).to_string()) + }; + + let signer_addr = core_signer_handle as usize; + let wallet = + unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet_handle, Clone::clone)); + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: as in `platform_wallet_send_dashpay_payment` — the caller pins + // the resolver for this call, and both wrappers are dropped when the + // worker task completes, before this function returns. + let signer = unsafe { + MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + let provider = unsafe { + resolver_contact_crypto_provider( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + let task_wallet = std::sync::Arc::clone(&wallet); + let result = block_on_worker(async move { + task_wallet + .identity() + .dashpay() + .prepare_payment( + task_wallet.core(), + &from_id, + &to_id, + amount_duffs, + memo_str, + &signer, + &provider, + ) + .await + }); + let payment = match result { + Ok(payment) => payment, + Err(e @ platform_wallet::PlatformWalletError::SeedMismatch { .. }) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e.to_string(), + ); + } + Err(e) => return e.into(), + }; + // The signer ran with no gate held, so the wallet may have been removed + // (and its teardown sweep already run) meanwhile. Check liveness and + // publish under this generation's gate, as `core_wallet_tx_builder_finalize` + // does, so no handle to a removed generation is ever published. + let (lifecycle, wallet_is_live) = runtime().block_on(async { + let gate = wallet.core().generation_payment_guard().await; + let live = wallet.core().is_current_generation().await; + (gate, live) + }); + if !wallet_is_live { + drop(lifecycle); + abandon_prepared(FFIPreparedDashPayPayment { + wallet: std::sync::Arc::clone(&wallet), + payment, + }); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "wallet is no longer registered in the manager (removed or re-created while the \ + payment was being signed); no payment handle was published" + .to_string(), + ); + } + let fee = payment.fee_duffs(); + let total = payment.total_duffs(); + let txid = txid_bytes(&payment.txid()); + let handle = PREPARED_DASHPAY_PAYMENT_STORAGE.insert(FFIPreparedDashPayPayment { + wallet: std::sync::Arc::clone(&wallet), + payment, + }); + drop(lifecycle); unsafe { - std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_txid.cast::(), 32); + *out_payment = handle; + *out_fee_duffs = fee; + *out_total_duffs = total; + *out_txid = txid; + } + PlatformWalletFFIResult::ok() +} + +/// Broadcast a prepared contact payment and record it. Consumes the handle +/// on every outcome. +/// +/// A definitive rejection (`ErrorTransactionBroadcastRejected`) or a stale +/// handle (`ErrorStaleReservationToken`, 34) sent nothing: the inputs are +/// released, and the contact address is left or returned available unless +/// the wallet has since seen it paid. An unconfirmed outcome may have +/// reached the network and keeps both. A payment whose contact address +/// another payment took while it waited is refused with the same +/// `ErrorStaleReservationToken` (34), nothing sent, inputs released: prepare +/// again. `wallet_handle` must name the wallet the payment +/// was prepared on, still registered: otherwise nothing is sent and the +/// payment is abandoned. +/// +/// `out_txid` is written on success, and also on a definitive rejection and +/// an unconfirmed outcome, as `core_wallet_broadcast_signed_transaction` +/// does: after an unconfirmed outcome it is the id of the transaction that +/// may be on the network, to watch for instead of paying again. +/// +/// # Safety +/// - `out_txid` must point to 32 writable bytes; `out_fee_duffs` may be NULL +/// and is written only on success. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_send_prepared_dashpay_payment( + wallet_handle: Handle, + payment_handle: Handle, + out_txid: *mut [u8; 32], + out_fee_duffs: *mut u64, +) -> PlatformWalletFFIResult { + // Ownership crosses into this call; every failure below abandons — + // including a bad output pointer, so a caller that already dropped its + // copy of the handle never strands the payment. + let prepared = + unwrap_option_or_return!(PREPARED_DASHPAY_PAYMENT_STORAGE.remove(payment_handle)); + if out_txid.is_null() { + abandon_prepared(prepared); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorNullPointer, + "out_txid is null; the payment was NOT broadcast and was abandoned".to_string(), + ); + } + let Some(wallet) = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, Clone::clone) else { + abandon_prepared(prepared); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "invalid platform wallet handle".to_string(), + ); + }; + if !wallet + .core() + .is_same_generation(prepared.payment.core_wallet()) + { + abandon_prepared(prepared); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "payment was prepared by a different wallet generation".to_string(), + ); } + + let FFIPreparedDashPayPayment { + wallet: origin, + payment, + } = prepared; + let local_txid = payment.txid(); + // `send_prepared_payment` holds the generation's lifecycle gate across + // its liveness check and the broadcast, so a removed wallet's payment + // never reaches the network; it is abandoned and refused as + // `WalletRemovedBeforeSend`. No gate is taken here: the lock is not + // reentrant. + let result = block_on_worker(async move { + origin + .identity() + .dashpay() + .send_prepared_payment(payment) + .await + }); + let outcome = result.map(|(txid, _entry, fee_duffs)| (txid, fee_duffs)); + let (txid, fee_duffs, ffi_result) = prepared_send_outcome(outcome, local_txid); + if let Some(txid) = txid { + unsafe { *out_txid = txid }; + } + if let Some(fee_duffs) = fee_duffs { + if !out_fee_duffs.is_null() { + unsafe { *out_fee_duffs = fee_duffs }; + } + } + ffi_result +} + +/// What a prepared send writes back: the txid when the transaction went +/// out, may have, or was rejected; the fee only on success. Any other +/// error — `WalletRemovedBeforeSend` (`NotFound`) among them — sent nothing +/// and carries no txid. +fn prepared_send_outcome( + result: Result<(dashcore::Txid, u64), platform_wallet::PlatformWalletError>, + local_txid: dashcore::Txid, +) -> (Option<[u8; 32]>, Option, PlatformWalletFFIResult) { + let (result, fee_duffs) = match result { + Ok((txid, fee_duffs)) => (Ok(txid), Some(fee_duffs)), + Err(e) => (Err(e), None), + }; + let (txid, ffi_result) = classify_broadcast_result(result, local_txid); + (txid.map(|txid| txid_bytes(&txid)), fee_duffs, ffi_result) +} + +/// A txid as the 32 bytes the FFI hands out. +fn txid_bytes(txid: &dashcore::Txid) -> [u8; 32] { + use dashcore::hashes::Hash; + txid.to_raw_hash().to_byte_array() +} + +/// Drop a prepared contact payment without broadcasting it: its inputs are +/// released. Nothing is persisted. Consumes the handle. +#[no_mangle] +pub extern "C" fn platform_wallet_abandon_prepared_dashpay_payment( + payment_handle: Handle, +) -> PlatformWalletFFIResult { + let prepared = + unwrap_option_or_return!(PREPARED_DASHPAY_PAYMENT_STORAGE.remove(payment_handle)); + abandon_prepared(prepared); PlatformWalletFFIResult::ok() } +/// Idempotent cleanup for a prepared-payment handle: abandons it if it is +/// still held, does nothing otherwise. +#[no_mangle] +pub extern "C" fn platform_wallet_prepared_dashpay_payment_free(payment_handle: Handle) { + if let Some(prepared) = PREPARED_DASHPAY_PAYMENT_STORAGE.remove(payment_handle) { + abandon_prepared(prepared); + } +} + /// Glue adapter implementing platform-wallet's [`ContactCryptoProvider`] over /// the resolver-backed [`MnemonicResolverCoreSigner`]. The orphan rule needs /// the impl's type local to this crate, so the signer is wrapped here rather @@ -1442,6 +1737,439 @@ mod tests { ); } + /// Prepare rejects each null required pointer with `ErrorNullPointer` + /// before any wallet lookup, and publishes no payment handle. + #[test] + fn should_reject_null_pointers_on_prepare() { + let id = [0u8; 32]; + let dummy_signer = std::ptr::dangling_mut::(); + let mut payment: Handle = 0x5EED; + let mut fee: u64 = 0; + let mut total: u64 = 0; + let mut txid = [0u8; 32]; + type Outs = ( + *mut MnemonicResolverHandle, + *mut Handle, + *mut u64, + *mut u64, + *mut [u8; 32], + ); + let cases: [Outs; 5] = [ + ( + std::ptr::null_mut(), + &mut payment, + &mut fee, + &mut total, + &mut txid, + ), + ( + dummy_signer, + std::ptr::null_mut(), + &mut fee, + &mut total, + &mut txid, + ), + ( + dummy_signer, + &mut payment, + std::ptr::null_mut(), + &mut total, + &mut txid, + ), + ( + dummy_signer, + &mut payment, + &mut fee, + std::ptr::null_mut(), + &mut txid, + ), + ( + dummy_signer, + &mut payment, + &mut fee, + &mut total, + std::ptr::null_mut(), + ), + ]; + for (signer, out_payment, out_fee, out_total, out_txid) in cases { + let r = unsafe { + platform_wallet_prepare_dashpay_payment( + 1, + id.as_ptr(), + id.as_ptr(), + 1_000, + std::ptr::null(), + signer, + out_payment, + out_fee, + out_total, + out_txid, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + assert_eq!(payment, 0x5EED, "no handle is written on a pointer failure"); + } + + /// An unknown wallet fails the prepare and leaves `*out_payment` at the + /// null handle, so a caller can never mistake a failure for a payment. + #[test] + fn should_publish_no_handle_when_preparing_for_an_unknown_wallet() { + let id = [0u8; 32]; + let dummy_signer = std::ptr::dangling_mut::(); + let mut payment: Handle = 0x5EED; + let mut fee: u64 = 7; + let mut total: u64 = 7; + let mut txid = [0xAAu8; 32]; + let r = unsafe { + platform_wallet_prepare_dashpay_payment( + 0xDEAD_BEEF, + id.as_ptr(), + id.as_ptr(), + 1_000, + std::ptr::null(), + dummy_signer, + &mut payment, + &mut fee, + &mut total, + &mut txid, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!(payment, NULL_HANDLE); + assert_eq!( + (fee, total, txid), + (7, 7, [0xAAu8; 32]), + "fee, total and txid are written only on success" + ); + } + + /// Send, abandon and free with a payment handle that is not (or no + /// longer) held: send and abandon report `NotFound` without touching the + /// outputs, and free stays a no-op however often it is called. + #[test] + fn should_consume_nothing_for_an_unknown_prepared_payment_handle() { + let mut txid = [0xAAu8; 32]; + let mut fee: u64 = 7; + let r = unsafe { + platform_wallet_send_prepared_dashpay_payment( + 0xDEAD_BEEF, + 0xDEAD_BEEF, + &mut txid, + &mut fee, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!(txid, [0xAAu8; 32]); + assert_eq!(fee, 7); + + // A null `out_txid` with a handle that is not held is still a lookup + // miss: nothing to abandon, nothing written. + let r = unsafe { + platform_wallet_send_prepared_dashpay_payment( + 0xDEAD_BEEF, + 0xDEAD_BEEF, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + for _ in 0..2 { + let r = platform_wallet_abandon_prepared_dashpay_payment(0xDEAD_BEEF); + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + platform_wallet_prepared_dashpay_payment_free(0xDEAD_BEEF); + } + } + + type TestCore = platform_wallet::CoreWallet; + + /// Try to sign a payment on `core`'s single fixture UTXO, reserving it. + fn try_finalize_on_the_only_input( + core: &TestCore, + signer: &platform_wallet::test_support::WalletSigner, + tag: u8, + ) -> Result { + use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; + use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; + + runtime().block_on(core.finalize_transaction( + TransactionBuilder::new().add_output( + &dashcore::Address::dummy(dashcore::Network::Testnet, usize::from(tag)), + 1_000_000, + ), + &[AccountTypePreference::BIP44], + 0, + signer, + )) + } + + /// Sign a payment on `core`'s single fixture UTXO, reserving it. + fn finalize_on_the_only_input( + core: &TestCore, + signer: &platform_wallet::test_support::WalletSigner, + tag: u8, + ) -> platform_wallet::SignedCoreTransaction { + try_finalize_on_the_only_input(core, signer, tag).expect("the fixture UTXO is free") + } + + /// Prove a cleanup branch released the payment's funding reservation: + /// the wallet's only UTXO funds a new payment. + fn assert_inputs_released( + core: &TestCore, + signer: &platform_wallet::test_support::WalletSigner, + tag: u8, + ) { + let rebuilt = finalize_on_the_only_input(core, signer, tag); + runtime().block_on(core.abandon_transaction(&rebuilt)); + } + + /// Prove the payment's funding is reserved: a second payment finds no + /// spendable input, so a release check after a cleanup branch is not + /// passing vacuously. + fn assert_inputs_held( + core: &TestCore, + signer: &platform_wallet::test_support::WalletSigner, + tag: u8, + ) { + let result = try_finalize_on_the_only_input(core, signer, tag); + assert!( + matches!( + result, + Err(platform_wallet::PlatformWalletError::CoreInsufficientFunds { .. }) + ), + "the held payment reserves the wallet's only UTXO, got {result:?}" + ); + } + + /// A held prepared payment whose transaction reserves the funded core + /// wallet's only UTXO, so a cleanup branch that skips the abandon leaves + /// that input unselectable. The abandon goes through the payment's own + /// core wallet; `wallet` only carries the handle, and the manager is + /// returned to keep it alive for the test. + fn live_prepared_payment_handle( + tag: u8, + ) -> ( + std::sync::Arc< + platform_wallet::PlatformWalletManager< + platform_wallet::test_support::NoopTestPersister, + >, + >, + Handle, + TestCore, + platform_wallet::test_support::WalletSigner, + ) { + use dashcore::hashes::Hash; + use key_wallet::account::account_type::StandardAccountType; + use platform_wallet::test_support::{funded_spv_core_wallet, test_platform_wallet_manager}; + use platform_wallet::PreparedDashPayPayment; + + let (manager, wallet) = runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + (manager, wallet) + }); + let (core, signer) = + runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account)); + let signed = finalize_on_the_only_input(&core, &signer, tag); + assert_inputs_held(&core, &signer, tag.wrapping_add(100)); + let address = dashcore::Address::new( + dashcore::Network::Testnet, + dashcore::address::Payload::PubkeyHash(dashcore::PubkeyHash::all_zeros()), + ); + let payment = PreparedDashPayPayment::new_for_test( + core.clone(), + signed, + address, + dpp::prelude::Identifier::from([1u8; 32]), + dpp::prelude::Identifier::from([2u8; 32]), + 1_000, + ); + let handle = + PREPARED_DASHPAY_PAYMENT_STORAGE.insert(FFIPreparedDashPayPayment { wallet, payment }); + (manager, handle, core, signer) + } + + /// What a prepared send hands back for each outcome: the txid whenever + /// the transaction went out, may have, or was rejected; the fee only on + /// success; nothing for the pre-network `WalletRemovedBeforeSend` refusal or a + /// stale payment. + #[test] + fn should_carry_the_txid_for_every_send_outcome_that_built_it() { + use dashcore::hashes::Hash; + use platform_wallet::PlatformWalletError; + let local = dashcore::Txid::from_byte_array([0x42; 32]); + + let (txid, fee, r) = prepared_send_outcome(Ok((local, 226)), local); + assert_eq!(r.code, PlatformWalletFFIResultCode::Success); + assert_eq!((txid, fee), (Some([0x42; 32]), Some(226))); + + let (txid, fee, r) = prepared_send_outcome( + Err(PlatformWalletError::TransactionBroadcastUnconfirmed( + "timeout".to_string(), + )), + local, + ); + assert_eq!( + r.code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed + ); + assert_eq!( + (txid, fee), + (Some([0x42; 32]), None), + "the host can watch the transaction that may be on the network" + ); + + let (txid, fee, _) = prepared_send_outcome( + Err(PlatformWalletError::TransactionBroadcast( + "rejected".to_string(), + )), + local, + ); + assert_eq!((txid, fee), (Some([0x42; 32]), None)); + + let (txid, fee, r) = prepared_send_outcome( + Err(PlatformWalletError::WalletRemovedBeforeSend( + "00".to_string(), + )), + local, + ); + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!((txid, fee), (None, None)); + + let (txid, fee, r) = + prepared_send_outcome(Err(PlatformWalletError::StaleReservation), local); + assert_eq!( + r.code, + PlatformWalletFFIResultCode::ErrorStaleReservationToken + ); + assert_eq!((txid, fee), (None, None)); + } + + /// A held payment sent with a null `out_txid` is refused before anything + /// is sent, its inputs are released, and the handle is consumed on the + /// way: a second send and an abandon both find nothing. + #[test] + fn should_abandon_and_consume_a_prepared_payment_with_a_null_out_txid() { + let (_manager, payment, core, signer) = live_prepared_payment_handle(60); + let mut fee: u64 = 7; + let r = unsafe { + platform_wallet_send_prepared_dashpay_payment( + 0xDEAD_BEEF, + payment, + std::ptr::null_mut(), + &mut fee, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert_eq!(fee, 7, "nothing is written on a refusal"); + assert_inputs_released(&core, &signer, 61); + + let mut txid = [0xAAu8; 32]; + let r = unsafe { + platform_wallet_send_prepared_dashpay_payment(0xDEAD_BEEF, payment, &mut txid, &mut fee) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!(txid, [0xAAu8; 32]); + assert_eq!( + platform_wallet_abandon_prepared_dashpay_payment(payment).code, + PlatformWalletFFIResultCode::NotFound + ); + } + + /// A held payment sent through a wallet handle that is not registered is + /// abandoned — its inputs released — and consumed, with nothing written. + #[test] + fn should_abandon_and_consume_a_prepared_payment_sent_through_an_unknown_wallet() { + let (_manager, payment, core, signer) = live_prepared_payment_handle(62); + let mut txid = [0xAAu8; 32]; + let mut fee: u64 = 7; + let r = unsafe { + platform_wallet_send_prepared_dashpay_payment(0xDEAD_BEEF, payment, &mut txid, &mut fee) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!((txid, fee), ([0xAAu8; 32], 7)); + assert_inputs_released(&core, &signer, 63); + assert_eq!( + platform_wallet_abandon_prepared_dashpay_payment(payment).code, + PlatformWalletFFIResultCode::NotFound + ); + } + + /// A held payment sent through a registered wallet it was not prepared on + /// is refused as `ErrorInvalidParameter`, abandoned — its inputs + /// released — and consumed, with nothing written. The FFI generation + /// check and the native same-wallet check both refuse this pairing; the + /// test pins the outcome either way, and which wallets + /// `is_same_generation` tells apart is covered natively. + #[test] + fn should_abandon_and_consume_a_prepared_payment_sent_through_another_wallet() { + let (_manager, payment, core, signer) = live_prepared_payment_handle(68); + // A wallet of its own manager: not the payment's, nor the handle's. + let (_unrelated_manager, unrelated) = runtime().block_on(async { + let (manager, wallet_id) = + platform_wallet::test_support::test_platform_wallet_manager().await; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet present"); + (manager, wallet) + }); + let wallet_handle = PLATFORM_WALLET_STORAGE.insert(unrelated); + let mut txid = [0xAAu8; 32]; + let mut fee: u64 = 7; + let r = unsafe { + platform_wallet_send_prepared_dashpay_payment( + wallet_handle, + payment, + &mut txid, + &mut fee, + ) + }; + PLATFORM_WALLET_STORAGE.remove(wallet_handle); + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidParameter); + assert_eq!((txid, fee), ([0xAAu8; 32], 7)); + assert_inputs_released(&core, &signer, 69); + assert_eq!( + platform_wallet_abandon_prepared_dashpay_payment(payment).code, + PlatformWalletFFIResultCode::NotFound + ); + } + + /// Abandoning a held payment releases its inputs and consumes the handle: + /// a repeated abandon finds nothing, and a free after it is a no-op. + #[test] + fn should_release_inputs_once_when_a_prepared_payment_is_abandoned() { + let (_manager, payment, core, signer) = live_prepared_payment_handle(64); + assert_eq!( + platform_wallet_abandon_prepared_dashpay_payment(payment).code, + PlatformWalletFFIResultCode::Success + ); + assert_inputs_released(&core, &signer, 65); + assert_eq!( + platform_wallet_abandon_prepared_dashpay_payment(payment).code, + PlatformWalletFFIResultCode::NotFound + ); + platform_wallet_prepared_dashpay_payment_free(payment); + } + + /// Freeing a held payment abandons it, releasing its inputs; a second + /// free and an abandon after it find nothing. + #[test] + fn should_release_inputs_once_when_a_prepared_payment_is_freed() { + let (_manager, payment, core, signer) = live_prepared_payment_handle(66); + platform_wallet_prepared_dashpay_payment_free(payment); + assert_inputs_released(&core, &signer, 67); + platform_wallet_prepared_dashpay_payment_free(payment); + assert_eq!( + platform_wallet_abandon_prepared_dashpay_payment(payment).code, + PlatformWalletFFIResultCode::NotFound + ); + } + /// A null `core_signer_handle` is rejected with `ErrorNullPointer`, and /// `*out_address` is already nulled when it is, so a cleanup-on-error /// caller that did not pre-initialize the slot never frees garbage. diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 0bc67f532c9..d14d197ddb2 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -336,6 +336,11 @@ pub enum PlatformWalletFFIResultCode { /// token-less build skips its unguarded by-outpoint release past the bound /// (leaving the aged outpoint to key-wallet's TTL, since releasing it /// unguarded could free an unrelated newer build's reservation). + /// + /// Also maps `PlatformWalletError::PreparedPaymentAddressTaken`: a prepared + /// DashPay contact payment whose address another payment took (or that + /// left the contact's pool) before the send. Same contract: nothing sent, + /// inputs released, prepare the payment again. ErrorStaleReservationToken = 34, /// Maps `SignedPaymentError::StaleToken`. The deferred reservation token is @@ -951,6 +956,17 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::StaleReservation => { PlatformWalletFFIResultCode::ErrorStaleReservationToken } + // A prepared contact payment whose address is no longer free: + // the same "nothing sent, inputs released, prepare again" contract + // as a stale prepared payment, so hosts handle both with one code. + PlatformWalletError::PreparedPaymentAddressTaken(_) => { + PlatformWalletFFIResultCode::ErrorStaleReservationToken + } + // Refused before the network, payment abandoned: the same code + // the lifecycle refusals elsewhere use. + PlatformWalletError::WalletRemovedBeforeSend(_) => { + PlatformWalletFFIResultCode::NotFound + } // A coin selection that picked an input still held by an in-flight // broadcast dispatch. Typed on the Rust side (it carries the // conflicting `OutPoint`; see the variant docs for the retry @@ -1757,6 +1773,18 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + /// A prepared payment refused because its address was taken carries the + /// same "prepare again" contract as a stale one, so it shares code 34. + #[test] + fn should_map_a_taken_prepared_payment_address_to_the_stale_reservation_code() { + let result: PlatformWalletFFIResult = + PlatformWalletError::PreparedPaymentAddressTaken("taken".to_string()).into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorStaleReservationToken + ); + } + /// The finalized-transaction handle age guard /// (`core_wallet_broadcast_signed_transaction` → `broadcast_finalized_transaction`) /// surfaces `PlatformWalletError::StaleReservation` through the blanket diff --git a/packages/rs-platform-wallet-ffi/src/handle.rs b/packages/rs-platform-wallet-ffi/src/handle.rs index defb3b62c7a..d0034fe9fc7 100644 --- a/packages/rs-platform-wallet-ffi/src/handle.rs +++ b/packages/rs-platform-wallet-ffi/src/handle.rs @@ -137,6 +137,13 @@ pub static CORE_SIGNED_TRANSACTION_STORAGE: Lazy< HandleStorage, > = Lazy::new(HandleStorage::new); +/// Prepared (signed, not yet broadcast) DashPay contact payments. Removed +/// exactly once by send, abandon or free; repeating any of them is a safe +/// invalid-handle error. +pub static PREPARED_DASHPAY_PAYMENT_STORAGE: Lazy< + HandleStorage, +> = Lazy::new(HandleStorage::new); + /// Storage for AssetLockManager handles (pinned to `SpvBroadcaster`). pub static ASSET_LOCK_MANAGER_STORAGE: Lazy< HandleStorage< diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 4d1adad6356..dd0675fca9f 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -822,6 +822,9 @@ pub(crate) async fn remove_wallet_and_tear_down_generation< .remove_entries_for_wallet(core); crate::handle::CORE_SIGNED_TRANSACTION_STORAGE .remove_matching(|tx| tx.wallet.is_same_generation(core)); + crate::handle::PREPARED_DASHPAY_PAYMENT_STORAGE.remove_matching(|prepared| { + prepared.payment.core_wallet().is_same_generation(core) + }); }) .await?; Ok(()) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 09e06b1fe14..8d92ff8b598 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -4731,26 +4731,16 @@ fn build_core_address_entry_ffi( pool_type_tag, address_index: info.index, // `CoreAddressEntryFFI` carries a single `is_used` bool with no - // slot for a reservation, so `Reserved` can only flatten to - // `false` and would reload as `Available` — silently returning a - // handed-out address to the pool. Nothing in platform reserves - // addresses (no caller of `next_receive_address_and_reserve` / - // `next_unused_and_reserve` in the workspace), so that arm is - // unreachable today. It is spelled out rather than folded into a - // catch-all so the first reserving caller shows up as an explicit - // schema decision instead of losing state on the next reload. + // slot for a reservation. The row is a reuse guard, so `Reserved` + // flattens to `true`: a handed-out address must not be handed out + // again after a reload. Its release is a later `Available` row, + // stored as `false`, which the host applies as written. The + // reserving callers are the DashPay contact sends — `send_payment` + // and the two-step `send_prepared_payment` — which reserve the + // contact address for the length of their broadcast. is_used: match info.state { - AddressState::Used => true, + AddressState::Used | AddressState::Reserved { .. } => true, AddressState::Available => false, - AddressState::Reserved { .. } => { - tracing::warn!( - index = info.index, - "persist: address pool entry is reserved, but the persisted \ - address schema cannot represent a reservation; it will \ - reload as available and may be handed out again" - ); - false - } }, balance: info.balance, address_base58: address_ptr, @@ -4848,8 +4838,8 @@ unsafe fn address_info_from_ffi( index: entry.address_index, path, // The persisted entry only carries `is_used`, so state round-trips - // to `Used`/`Available`; a `Reserved` entry is not representable — - // see `build_core_address_entry_ffi`. + // to `Used`/`Available`; a `Reserved` entry is stored as used and + // reloads `Used` — see `build_core_address_entry_ffi`. state: if entry.is_used { AddressState::Used } else { @@ -11159,13 +11149,11 @@ mod tests { /// Pin what the persisted address row does to `AddressState`. /// `CoreAddressEntryFFI` carries a single `is_used` bool, so only - /// `Used` and `Available` survive a save/load cycle intact; - /// `Reserved { .. }` has no representation and comes back - /// `Available`, i.e. a reload silently frees a handed-out address. - /// Nothing in platform reserves addresses today, so this records the - /// boundary rather than a live bug: if a reserving caller is ever - /// added, this test is where the schema decision (a dedicated - /// reservation field on the row) has to be made. + /// `Used` and `Available` survive a save/load cycle intact. + /// `Reserved { .. }` flattens to used: the row is a reuse guard, and a + /// reload must never hand out an address that was handed out (the + /// two-step DashPay contact payment reserves its address across the + /// broadcast). A released reservation is stored as `Available`. #[test] fn address_state_round_trip_keeps_used_and_flattens_reserved() { for (state, expected) in [ @@ -11173,7 +11161,7 @@ mod tests { (AddressState::Used, AddressState::Used), ( AddressState::Reserved { at: 1_700_000_000 }, - AddressState::Available, + AddressState::Used, ), ] { let mut info = typed_key_test_address_info(11, None); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_pool_writer.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_pool_writer.rs index d20510fd51c..4a8922b3da4 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_pool_writer.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_pool_writer.rs @@ -351,6 +351,86 @@ fn used_address_cannot_regain_reservation_from_stale_snapshot() { assert_eq!(state, (1, None)); } +/// A reservation is a claim the backend can take back: storing the address +/// available again clears it, while storing it used makes it final. This is +/// what a two-step contact payment relies on to release the address of a +/// send that never reached the network, which a `used` flag (monotonic, MAX) +/// could not express. +#[test] +fn should_release_a_reservation_or_promote_it_to_used() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB3); + ensure_wallet_meta(&persister, &w); + let account_type = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let mut infos = external_infos(0xB3); + infos.truncate(2); + let store = |infos: Vec| { + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![pool_entry( + account_type, + AddressPoolType::External, + infos, + )], + ..Default::default() + }, + ) + .unwrap(); + }; + + // Both reserved, as a send stores them before its broadcast. + for info in &mut infos { + info.state = AddressState::Reserved { at: 1_752_528_625 }; + } + store(infos.clone()); + // Index 0: rejected — released. Index 1: sent — used. + infos[0].state = AddressState::Available; + infos[1].state = AddressState::Used; + let (released, sent) = (infos[0].address.clone(), infos[1].address.clone()); + store(infos); + + // What a reload restores as used — the loader `load()` rehydrates the + // pool from: the sent address, never the released one. + { + let conn = persister.lock_conn_for_test(); + let reloaded: Vec<_> = core_pool::load_used_addresses(&conn, &w, Network::Testnet) + .unwrap() + .into_iter() + .map(|(address, _)| address) + .collect(); + assert_eq!( + reloaded, + vec![sent], + "released {released} must reload available" + ); + } + + let conn = persister.lock_conn_for_test(); + let mut stmt = conn + .prepare( + "SELECT used, reserved_at FROM core_address_pool \ + WHERE wallet_id = ?1 ORDER BY address_index", + ) + .unwrap(); + let rows = stmt + .query_map(rusqlite::params![w.as_slice()], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?)) + }) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!( + rows, + vec![(0, None), (1, None)], + "the released address is available on disk, the sent one used" + ); +} + /// UTXOs resolve to their pool-declared account during reads. #[test] fn account_index_is_resolved_from_pool() { diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 5696d681d2b..ac0c23ba4d3 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -65,6 +65,23 @@ pub enum PlatformWalletError { #[error("Wallet not found: {0}")] WalletNotFound(String), + /// A payment refused before anything reached the network: its wallet + /// was removed or re-created after the handle it came through was + /// taken. A prepared payment refused at send was abandoned; a build + /// refused at funding reserved nothing. Distinct from + /// [`Self::WalletNotFound`] so a host can tell the user nothing was sent + /// without trusting where a lookup failed. + #[error("wallet {0} was removed or re-created before the payment was sent; nothing was sent")] + WalletRemovedBeforeSend(String), + + /// A prepared contact payment refused at send because its address is no + /// longer free: another payment to the contact took it, a payment was + /// seen at it on-chain, or it left the contact's pool. Nothing was sent + /// and the inputs were released; preparing the payment again picks a + /// fresh address. + #[error("{0}; nothing was sent — prepare the payment again")] + PreparedPaymentAddressTaken(String), + #[error("Wallet already exists: {0}")] WalletAlreadyExists(String), diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 55a9695f34c..11170cad5f3 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -75,8 +75,8 @@ pub use masternode::{ pub use wallet::core_address_key::CoreAddressPrivateKey; pub use wallet::identity::network::{ derive_identity_auth_keypair, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, - ContactInfoPublishOutcome, ContactInfoSealed, SeedBindingVerification, IDENTITY_GAP_LIMIT, - MASTER_KEY_INDEX, + ContactInfoPublishOutcome, ContactInfoSealed, PreparedDashPayPayment, SeedBindingVerification, + IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; pub use wallet::identity::{ calculate_account_reference, derive_auto_accept_private_key, derive_contact_payment_address, diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 92924440e97..ed78899cb44 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -641,6 +641,16 @@ impl CoreWallet { let (wallet, info) = manager .get_wallet_and_info_mut(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound("wallet not found".into()))?; + // Bound to this handle's generation, as the release is: a wallet + // re-created under the same id since the caller last checked would + // otherwise reserve the NEW generation's inputs under a build + // stamped with the old one, which the generation-bound release + // then refuses to free. + if !Arc::ptr_eq(&info.generation, self.generation()) { + return Err(PlatformWalletError::WalletRemovedBeforeSend(hex::encode( + self.wallet_id, + ))); + } let height = info.core_wallet.last_processed_height(); @@ -1019,8 +1029,8 @@ mod tests { use crate::broadcaster::TransactionBroadcaster; use crate::test_support::{ funded_wallet_manager, funded_wallet_manager_dual_standard, - funded_wallet_manager_with_contact, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, - AlwaysRejectedBroadcaster, WalletSigner, + funded_wallet_manager_with_contact, funded_wallet_manager_with_outputs, + AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, }; use key_wallet::wallet::managed_wallet_info::fee::{estimate_tx_size, FeeRate}; @@ -1661,6 +1671,63 @@ mod tests { ); } + /// A handle whose wallet was re-created under the same id since the + /// caller last checked funds nothing: the build is refused before it + /// reserves the new generation's inputs, which the old handle's + /// generation-bound release could never free. + #[tokio::test] + async fn should_reserve_nothing_when_finalizing_from_a_recreated_generation() { + let (manager, wallet_id, generation, signer) = + funded_wallet_manager_with_outputs(StandardAccountType::BIP44Account, &[1_200_000]) + .await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let broadcaster = Arc::new(AlwaysOkBroadcaster); + let stale = CoreWallet::new( + Arc::clone(&sdk), + Arc::clone(&manager), + wallet_id, + Arc::clone(&broadcaster), + generation, + ); + let fresh_generation = Arc::new(crate::wallet::core::WalletGeneration::new()); + { + let mut wm = manager.write().await; + let (_, info) = wm + .get_wallet_and_info_mut(&wallet_id) + .expect("wallet present in manager"); + info.generation = Arc::clone(&fresh_generation); + } + + let refused = stale + .finalize_transaction( + payment_builder(50), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await; + assert!( + matches!( + refused, + Err(PlatformWalletError::WalletRemovedBeforeSend(_)) + ), + "got {refused:?}" + ); + // One coin: the re-created generation can only fund this if the + // refused build left it unreserved. + let fresh = CoreWallet::new(sdk, manager, wallet_id, broadcaster, fresh_generation); + let funded = fresh + .finalize_transaction( + payment_builder(51), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await + .expect("the refused build reserved nothing"); + fresh.abandon_transaction(&funded).await; + } + fn payment_builder(tag: u8) -> TransactionBuilder { TransactionBuilder::new().add_output( &DashAddress::dummy(Network::Testnet, usize::from(tag)), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 8e121a95005..e8af7ec0136 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -72,6 +72,8 @@ pub(crate) use payment_handler::DashPayPaymentHandler; pub(crate) use payment_handler::run_dashpay_payment_hooks; mod payments; pub(crate) use payments::{record_incoming_dashpay_payments, sent_payment_status_for_record}; +mod prepared_payment; +pub use prepared_payment::PreparedDashPayPayment; mod profile; pub(crate) mod sdk_writer; mod seed_binding; 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 f6a4f3919b1..0f396096316 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1,9 +1,11 @@ //! DashPay payment recording and send-to-contact flows. use dpp::prelude::Identifier; +use key_wallet::account::account_collection::DashpayAccountKey; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use std::collections::HashSet; use std::sync::Arc; use tokio::sync::RwLock; @@ -11,10 +13,17 @@ use tokio::sync::RwLock; use key_wallet_manager::WalletManager; use super::contacts::receiving_scan_checkpoint; +use super::prepared_payment::{ + contact_key, pool_changeset, release_address_reservation, reserve_address, AddressOutcome, + CONTACT_ACCOUNT_INDEX, +}; use super::*; use crate::broadcaster::TransactionBroadcaster; +use crate::changeset::PlatformWalletChangeSet; use crate::error::PlatformWalletError; +use crate::wallet::core::WalletGeneration; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; +use crate::wallet::reservations::release_reservation_after_rejected_broadcast; // --------------------------------------------------------------------------- // Incoming payment recording + reconcile @@ -1197,35 +1206,40 @@ fn record_received_payment_totals( recorded } -/// Return a consumed contact payment address to its pool: clear the used -/// mark and index, and recompute the used high-water. -/// -/// Sound ONLY while the transaction that consumed the address never reached -/// the network — a failed `build_signed`, or a broadcast the network -/// definitively rejected pre-send. The address was never exposed on-chain in -/// either case, so re-handing it later cannot break DIP-15 per-payment -/// rotation, and clearing the mark is what preserves the invariant -/// sent-payment reconstruction depends on: used indices chain within the gap -/// limit. Extra lookahead addresses the selection may have generated are -/// left in place — generated-but-available entries are harmless. -fn return_contact_payment_address_to_pool( - account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, +/// Release the reservation on a contact payment address whose transaction +/// never left this process. Caller holds the manager write lock the +/// reservation was taken under, so nothing has observed or persisted it since. +fn release_unsent_contact_address( + info: &mut PlatformWalletInfo, + key: &DashpayAccountKey, payment_address: &dashcore::Address, ) { - use key_wallet::managed_account::address_pool::AddressState; - - for pool in account.managed_account_type_mut().address_pools_mut() { - let Some(&index) = pool.address_index.get(payment_address) else { - continue; - }; - pool.used_indices.remove(&index); - if let Some(address_info) = pool.addresses.get_mut(&index) { - address_info.state = AddressState::Available; - } - pool.highest_used = pool.used_indices.iter().max().copied(); + if let Some(external_account) = info + .core_wallet + .accounts + .dashpay_external_accounts + .get_mut(key) + { + release_address_reservation(external_account, payment_address); } } +/// Whether a resident transaction record of this contact account pays +/// `script`. Records can be evicted once final, so this is a second check +/// next to the address's used flag, never the only one. +pub(super) fn contact_address_paid_on_chain( + account: &key_wallet::managed_account::ManagedCoreFundsAccount, + script: &dashcore::ScriptBuf, +) -> bool { + account.transactions().values().any(|record| { + record + .transaction + .output + .iter() + .any(|output| &output.script_pubkey == script) + }) +} + /// Order-independent digest of an enumerated wallet transaction table: /// SHA-256 over the sorted `(txid, spends_wallet_input)` rows. /// @@ -1323,55 +1337,58 @@ 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, +/// How [`reserve_contact_payment_address`] claims the address it selects. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ContactAddressClaim { + /// Marked used at once — an address handed to a payout this wallet does + /// not broadcast, which is never released. + Used, + /// Marked reserved, to be settled used or released once the broadcast's + /// outcome is known. A payment key-wallet observes at the address in the + /// meantime promotes it to used, and the release leaves that alone. + Reserved, +} + +/// The contact's external account and the xpub its addresses derive from. +/// With `generation`, a wallet removed or re-created since that generation +/// was taken is reported as not found. +pub(super) fn contact_external_account<'w>( + wm: &'w mut WalletManager, wallet_id: WalletId, + generation: Option<&Arc>, 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 +) -> Result< + ( + key_wallet::bip32::ExtendedPubKey, + &'w mut key_wallet::managed_account::ManagedCoreFundsAccount, + ), + PlatformWalletError, +> { + let key = contact_key(from_identity_id, to_contact_id); + // 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 contact_xpub = wm + .get_wallet(&wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))? + .accounts + .dashpay_external_accounts + .get(&key) + .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 external_account = 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 + .filter(|info| generation.is_none_or(|g| Arc::ptr_eq(&info.generation, g))) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))? .core_wallet .accounts .dashpay_external_accounts @@ -1382,30 +1399,110 @@ fn reserve_contact_payment_address( to_contact_id )) })?; + Ok((contact_xpub, external_account)) +} + +/// Mark used every next available address a resident record pays — paid by +/// another device on the same seed without the pool marking it — so the next +/// payment does not link to it on-chain. Each pass marks one more address, +/// so the walk ends. Returns whether any address was marked. +fn mark_contact_addresses_paid_on_chain( + external_account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, + contact_xpub: &key_wallet::bip32::ExtendedPubKey, +) -> Result { + // Collected only once a first candidate is found paid, the rare case: + // the walk then checks every further candidate against them. + let mut paid_scripts: Option> = None; + let mut marked = false; + loop { + let candidate = external_account + .next_address(Some(contact_xpub), true) + .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; + let script = candidate.script_pubkey(); + let paid = match &paid_scripts { + Some(scripts) => scripts.contains(&script), + None => contact_address_paid_on_chain(external_account, &script), + }; + if !paid { + return Ok(marked); + } + if paid_scripts.is_none() { + paid_scripts = Some( + external_account + .transactions() + .values() + .flat_map(|record| record.transaction.output.iter()) + .map(|output| output.script_pubkey.clone()) + .collect(), + ); + } + if !external_account.mark_address_used(&candidate) { + return Err(PlatformWalletError::TransactionBuild(format!( + "derived payment address {candidate} is not in the external account pool" + ))); + } + marked = true; + } +} + +/// Run [`mark_contact_addresses_paid_on_chain`] for one contact and, when it +/// marked anything, snapshot the pool to store after the guard drops. Caller +/// holds the manager write lock. +pub(super) fn record_contact_addresses_paid_on_chain( + wm: &mut WalletManager, + wallet_id: WalletId, + generation: Option<&Arc>, + from_identity_id: &Identifier, + to_contact_id: &Identifier, +) -> Result, PlatformWalletError> { + let (contact_xpub, external_account) = + contact_external_account(wm, wallet_id, generation, from_identity_id, to_contact_id)?; + Ok( + mark_contact_addresses_paid_on_chain(external_account, &contact_xpub)? + .then(|| pool_changeset(from_identity_id, to_contact_id, external_account)), + ) +} +/// Select and claim from the one authoritative pool shared by Core sends +/// and externally funded contact payouts. Run +/// [`record_contact_addresses_paid_on_chain`] first, so the selection skips +/// an address a resident record already pays. 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, + claim: ContactAddressClaim, +) -> Result<(dashcore::Address, PlatformWalletChangeSet), PlatformWalletError> { + let (contact_xpub, external_account) = + contact_external_account(wm, wallet_id, None, from_identity_id, 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) { + // gap-window is exhausted, generates) the next available address — + // it does NOT change its state. DIP-15 per-payment rotation requires + // that once we commit this address to a payment it never be handed + // out again, so claim it now, on the resident external account, + // before the snapshot below captures the pool. A miss 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. + let claimed = match claim { + ContactAddressClaim::Used => external_account.mark_address_used(&payment_address), + ContactAddressClaim::Reserved => reserve_address(external_account, &payment_address), + }; + if !claimed { return Err(PlatformWalletError::TransactionBuild(format!( "derived payment address {payment_address} is not in the external \ - account pool — cannot mark it used" + account pool — cannot claim it" ))); } - // Snapshot the used-flag flip (and any gap-window extension) - // `next_address` + `mark_address_used` just applied to the + // Snapshot the claim (and any gap-window extension) + // `next_address` + the claim 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, @@ -1416,20 +1513,9 @@ fn reserve_contact_payment_address( // 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 claim_changeset = pool_changeset(from_identity_id, to_contact_id, external_account); - Ok((payment_address, used_flip_changeset)) + Ok((payment_address, claim_changeset)) } // --------------------------------------------------------------------------- @@ -1459,11 +1545,20 @@ impl DashPayView<'_, B> { let _payment_guard = self.persister.lock_contact_payments().await; let (address, changeset) = { let mut wm = self.wallet_manager.write().await; + // Stored with the claim's own snapshot below. + record_contact_addresses_paid_on_chain( + &mut wm, + self.wallet_id, + None, + from_identity_id, + to_contact_id, + )?; reserve_contact_payment_address( &mut wm, self.wallet_id, from_identity_id, to_contact_id, + ContactAddressClaim::Used, )? }; // Host persistence may re-enter wallet APIs, so never invoke it under @@ -1542,11 +1637,10 @@ impl DashPayView<'_, B> { S: key_wallet::signer::Signer, C: crate::wallet::identity::network::contact_requests::ContactCryptoProvider + Sync, { - use key_wallet::account::account_collection::DashpayAccountKey; use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; - let account_index: u32 = 0; + let account_index = CONTACT_ACCOUNT_INDEX; // Complete any deferred contact-crypto build before resolving the // external account. The signerless sweep enqueues the original @@ -1570,22 +1664,75 @@ impl DashPayView<'_, B> { // of being allowed to write first and fail second. self.drain_pending_contact_crypto_verified(provider, None) .await?; + + // The generation's lifecycle gate, held through the broadcast and the + // record, as `send_prepared_payment` holds it: a teardown cannot + // complete while this payment is on its way to the network, and a + // wallet removed or re-created before it was taken sends nothing. + // Taken before the contact-payment gate, in the same order. + let lifecycle_generation = self + .wallet_manager + .read() + .await + .get_wallet_info(&self.wallet_id) + .map(|info| Arc::clone(&info.generation)) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let _lifecycle = lifecycle_generation.payment_guard().await; + let still_current = self + .wallet_manager + .read() + .await + .get_wallet_info(&self.wallet_id) + .is_some_and(|info| Arc::ptr_eq(&info.generation, &lifecycle_generation)); + if !still_current { + return Err(PlatformWalletError::WalletRemovedBeforeSend(hex::encode( + self.wallet_id, + ))); + } let _payment_guard = self.persister.lock_contact_payments().await; - let (payment_address, used_flip_changeset, tx, fee, funding_accounts, in_broadcast_pin) = { + // Addresses a resident record pays but the pool still shows + // available are marked used and stored first, on their own: the + // send below can still fail before it stores anything, and that + // evidence must not depend on it. A failed store fails the send + // before anything is reserved, and the host sees the store's error: + // carried on, a send that then failed to build would report only the + // build failure while the mark sat in memory alone. The mark stays in + // memory and rides the contact's next stored pool snapshot. (The + // prepared path likewise returns this store's failure, on refusal.) + let found_paid = { + let mut wm = self.wallet_manager.write().await; + record_contact_addresses_paid_on_chain( + &mut wm, + self.wallet_id, + None, + from_identity_id, + to_contact_id, + )? + }; + if let Some(changeset) = found_paid { + self.store_durably(changeset)?; + } + + let ( + payment_address, + reservation_changeset, + tx, + fee, + funding_accounts, + in_broadcast_pin, + generation, + ) = { let mut wm = self.wallet_manager.write().await; - let (payment_address, used_flip_changeset) = reserve_contact_payment_address( + let (payment_address, reservation_changeset) = reserve_contact_payment_address( &mut wm, self.wallet_id, from_identity_id, to_contact_id, + ContactAddressClaim::Reserved, )?; - let key = DashpayAccountKey { - index: account_index, - user_identity_id: from_identity_id.to_buffer(), - friend_identity_id: to_contact_id.to_buffer(), - }; + let key = contact_key(from_identity_id, 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)))?; @@ -1650,6 +1797,7 @@ impl DashPayView<'_, B> { } } if offered_accounts.is_empty() { + release_unsent_contact_address(info, &key, &payment_address); return Err(PlatformWalletError::TransactionBuild( "no spendable funding account (BIP44/BIP32/DashPay receiving) found" .to_string(), @@ -1671,29 +1819,16 @@ impl DashPayView<'_, B> { { Ok(built) => built, Err(e) => { - // Return the consumed address to the pool. Nothing was - // signed to completion, persisted (the used-flip store - // below is unreachable from here) or broadcast, so the - // mark exists only in this process's memory and the - // address was never exposed on-chain — un-marking it - // cannot break DIP-15 rotation. Leaving it consumed - // would let every failed build (insufficient funds - // retried by the user, a signer refusing) advance the - // next index by one with no bound; enough failures - // before one successful payment would put that payment - // beyond any gap-limit walk a restore-from-seed can - // perform, permanently hiding it from sent-payment - // reconstruction. Used indices must chain within the - // gap limit, so consumption is committed only once a - // fully signed transaction exists. - if let Some(external_account) = info - .core_wallet - .accounts - .dashpay_external_accounts - .get_mut(&key) - { - return_contact_payment_address_to_pool(external_account, &payment_address); - } + // Nothing was signed to completion, persisted (the + // reservation store below is unreachable from here) or + // broadcast: release the reservation. Leaving it would let + // every failed build (insufficient funds retried by the + // user, a signer refusing) skip one more index with no + // bound; enough of them before one successful payment + // would put that payment beyond any gap-limit walk a + // restore-from-seed can perform, hiding it from + // sent-payment reconstruction. + release_unsent_contact_address(info, &key, &payment_address); return Err(PlatformWalletError::TransactionBuild(e.to_string())); } }; @@ -1710,23 +1845,15 @@ impl DashPayView<'_, B> { // been held since selection — and it must sweep EVERY account // that offered funding (pooled selection), the same superset the // rejected-broadcast release below uses; accounts that supplied - // nothing no-op. Roll back the consumed payment address exactly - // like the build-failure arm above — nothing was persisted or - // broadcast. + // nothing no-op. Release the address reservation exactly like the + // build-failure arm above — nothing was persisted or broadcast. if let Some(outpoint) = info.generation.in_broadcast_conflict(&tx) { for at in &offered_accounts { if let Some(managed) = info.core_wallet.accounts.funds_account_mut(at) { managed.release_reservation(&tx); } } - if let Some(external_account) = info - .core_wallet - .accounts - .dashpay_external_accounts - .get_mut(&key) - { - return_contact_payment_address_to_pool(external_account, &payment_address); - } + release_unsent_contact_address(info, &key, &payment_address); // Typed, and the SAME variant the other two choke points // return — see `PlatformWalletError::InputMidBroadcast`. return Err(PlatformWalletError::InputMidBroadcast { outpoint }); @@ -1760,42 +1887,62 @@ impl DashPayView<'_, B> { ( payment_address, - used_flip_changeset, + reservation_changeset, tx, fee, offered_accounts, in_broadcast_pin, + info.generation.clone(), ) }; - // Persist the payment-address used flip now that the wallet-manager - // write guard is released, but BEFORE the broadcast below. Durability - // must precede broadcast: once the transaction is on the network the - // address is publicly spent-to, and a relaunch that rehydrated the - // pool from a stale snapshot would re-hand the SAME address to the - // next payment — breaking DIP-15 per-payment rotation and linking the - // payments on-chain. A store failure aborts the send pre-broadcast - // (nothing has hit the network); the consumed in-memory address only - // leaves a one-address gap that the pool's gap window absorbs on - // retry — bounded, because a signed transaction exists here, unlike - // the unbounded build-failure case rolled back above. - if let Err(e) = self.persister.store(used_flip_changeset) { + // Persist the address reservation now that the wallet-manager write + // guard is released, but BEFORE the broadcast below, and flushed on a + // buffering backend. Durability must precede broadcast: once the + // transaction is on the network the address is publicly spent-to, + // and a relaunch that rehydrated the pool from a stale snapshot would + // re-hand the SAME address to the next payment — breaking DIP-15 + // per-payment rotation and linking the payments on-chain. The FFI + // host reloads a stored reservation as used; SQLite does not yet + // rehydrate a contact's pool at all (dashpay/platform#5548). + if let Err(e) = self.store_durably(reservation_changeset) { // A definitive PRE-SEND failure: the broadcaster below was never - // reached, so the transaction provably is not on the wire and its - // inputs are safe to reselect immediately. Release the fence in the - // same breath as the abort — carrying it past this point would hold - // the inputs against every later build with no transaction to - // protect, and (with no deadline behind it) nothing would ever - // clear it. + // reached, so the transaction provably is not on the wire. Its + // address, its inputs and the fence are all released. The verdict + // goes on the pin first, synchronously, so a cancellation from + // here on settles the fence as released. The address goes back + // before the inputs, as in `send_prepared_payment`'s store-failure + // arm: the contact-payment gate is still held here, so no other + // build is between them. Then the inputs, under the still-live fence, + // and the fence last. Carrying the fence or the inputs past this + // point would hold them against every later build with no + // transaction to protect, and the user's immediate retry would + // fail on a spurious insufficient-funds. + let mut in_broadcast_pin = in_broadcast_pin; + in_broadcast_pin.settle_released_on_drop(); + self.settle_payment_address_gated( + &generation, + from_identity_id, + to_contact_id, + &payment_address, + AddressOutcome::NotSent, + ) + .await; + release_reservation_after_rejected_broadcast( + &self.wallet_manager, + &self.wallet_id, + &funding_accounts, + &tx, + None, + ) + .await; in_broadcast_pin.settle_released(); - return Err(PlatformWalletError::Persistence(format!( - "failed to persist payment-address used flip: {e}" - ))); + return Err(e); } // 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. + // this broadcast awaits the network; settling the reservation takes + // the gate again. drop(_payment_guard); // --- 3. Broadcast the transaction, releasing the build's UTXO @@ -1832,7 +1979,7 @@ impl DashPayView<'_, B> { let mut in_broadcast_pin = in_broadcast_pin; in_broadcast_pin.settle_released_on_drop(); // - // ORDER MATTERS — the cleanup runs FIRST, under the still-live + // ORDER MATTERS — the input cleanup runs under the still-live // fence, and only then does the pin come down. The cleanup is an // `.await`: it must re-acquire the wallet-manager read lock, and // on this path it carries NO reservation token, so it performs an @@ -1853,7 +2000,7 @@ impl DashPayView<'_, B> { // the unconditional release to clobber. `release_reservation_ // after_rejected_broadcast` documents this ordering requirement // for exactly this reason. - crate::wallet::reservations::release_reservation_after_rejected_broadcast( + release_reservation_after_rejected_broadcast( &self.wallet_manager, &self.wallet_id, &funding_accounts, @@ -1864,6 +2011,27 @@ impl DashPayView<'_, B> { ) .await; in_broadcast_pin.settle_released(); + // Then the address. The inputs go first, as in + // `send_prepared_payment`, whose finalized-broadcast path + // releases them itself: the address release waits on the + // contact-payment gate, and a send cancelled there must not + // keep its inputs reserved until the TTL. A build that takes + // the freed inputs before the address returns pays the next + // index; this one is available again, inside the gap window. + // Leaving it claimed would let every rejected send widen the + // off-chain gap in the used range by one, with no bound. Only + // the reservation is released: a payment the wallet observed + // at this address meanwhile — another device on the same + // seed — has already promoted it to used and keeps it so, even + // after a ChainLock evicted its record. + self.settle_payment_address( + &generation, + from_identity_id, + to_contact_id, + &payment_address, + AddressOutcome::NotSent, + ) + .await; Err(e) } other => { @@ -1873,80 +2041,19 @@ impl DashPayView<'_, B> { // still selectable here and must stay fenced until an observed // spend says otherwise. in_broadcast_pin.settle_pending_spend(); + // Went out or may have: the address becomes used. + self.settle_payment_address( + &generation, + from_identity_id, + to_contact_id, + &payment_address, + AddressOutcome::Sent, + ) + .await; other } }; - let txid = match broadcast_result { - Ok(txid) => txid, - Err(e) => { - // A definitive rejection means the transaction never reached - // the network, so the payment address was never exposed - // on-chain — but unlike the build-failure rollback above, its - // used flip WAS persisted (durability precedes broadcast). - // Return the address to the pool and persist the revert: - // leaving it consumed lets every definitively rejected send - // widen the off-chain gap in the used range by one, with no - // bound, until a later successful payment lands beyond any - // recovery walk — the same failure class as an unrolled-back - // build failure, one step later. An indeterminate broadcast - // 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| { - info.core_wallet - .accounts - .dashpay_external_accounts - .get_mut(&DashpayAccountKey { - index: account_index, - user_identity_id: from_identity_id.to_buffer(), - friend_identity_id: to_contact_id.to_buffer(), - }) - .map(|external_account| { - return_contact_payment_address_to_pool( - external_account, - &payment_address, - ); - crate::changeset::PlatformWalletChangeSet { - account_address_pools: - crate::changeset::account_address_pool_entries( - key_wallet::account::AccountType::DashpayExternalAccount { - index: account_index, - user_identity_id: from_identity_id.to_buffer(), - friend_identity_id: to_contact_id.to_buffer(), - }, - external_account - .managed_account_type() - .address_pools(), - ), - ..Default::default() - } - }) - }) - }; - match revert_changeset { - // Persisted outside the write guard, same as the flip - // itself. A failed revert store is logged, not fatal: - // the address stays consumed and the one-address gap - // is absorbed by the pool's gap window. - Some(changeset) => { - if let Err(persist_err) = self.persister.store(changeset) { - tracing::warn!( - error = %persist_err, - "failed to persist payment-address revert after rejected broadcast" - ); - } - } - None => tracing::warn!( - "external account not found while reverting payment address after rejected broadcast" - ), - } - } - return Err(e.into()); - } - }; + let txid = broadcast_result?; tracing::info!( from_identity = %from_identity_id, @@ -1958,6 +2065,12 @@ impl DashPayView<'_, B> { ); // --- 4. Record the outgoing payment on the sender's ManagedIdentity. --- + // The payment is on the wire from here on, so nothing below may fail + // the call: an error would read as "not sent" and invite the user to + // pay again. A record that cannot be written is logged instead, as in + // `send_prepared_payment`; the sent-payment reconstruction from the + // wallet's transaction history (`reconcile_sent_payments_from_tx_history`) + // rebuilds it, without the memo. let entry = crate::wallet::identity::types::dashpay::payment::PaymentEntry::new_sent( *to_contact_id, amount_duffs, @@ -1965,27 +2078,28 @@ impl DashPayView<'_, B> { ); { let mut wm = self.wallet_manager.write().await; - // A missing wallet info or unmanaged `from_identity_id` is a real - // state error, not a benign no-op: the tx is already broadcast - // on-chain but the local Sent entry + memo has no on-chain - // recovery, so swallowing the lookup miss here would lose the - // user's payment record with no signal. Fail loud (same rationale - // as the persist-failure propagation just below) so the UI can - // report the partial outcome (sent, but not recorded). - let info = wm + // Bound to the funding generation: a wallet re-created meanwhile + // must not receive this payment. + let recorded = match wm .get_wallet_info_mut(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - let managed = info - .identity_manager - .managed_identity_mut(from_identity_id) - .ok_or(PlatformWalletError::IdentityNotFound(*from_identity_id))?; - managed - .record_dashpay_payment(txid.to_string(), entry.clone(), &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "payment broadcast but not recorded locally: {e}" - )) - })?; + .filter(|info| Arc::ptr_eq(&info.generation, &generation)) + .and_then(|info| info.identity_manager.managed_identity_mut(from_identity_id)) + { + Some(managed) => managed + .record_dashpay_payment(txid.to_string(), entry.clone(), &self.persister) + .map_err(|e| e.to_string()), + None => Err( + "sender identity not found, or the wallet was removed or re-created" + .to_string(), + ), + }; + if let Err(e) = recorded { + tracing::error!( + %txid, + error = %e, + "DashPay payment broadcast but not recorded locally" + ); + } } Ok((txid, entry, fee)) @@ -2077,6 +2191,11 @@ mod tests { /// dropped — a slow backend held mid-write. account_gate: Mutex>>, gate_entered: std::sync::atomic::AtomicBool, + /// How many flushes the wallet asked for. + flushes: std::sync::atomic::AtomicUsize, + /// When set, every round recording a DashPay payment fails + /// transiently (and is not recorded). + fail_payment_records: Mutex, } impl PlatformWalletPersistence for RecordingPersister { @@ -2108,7 +2227,10 @@ mod tests { wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { - if *self.fail_stores.lock().unwrap() { + if *self.fail_stores.lock().unwrap() + || (*self.fail_payment_records.lock().unwrap() + && changeset.dashpay_payments_overlay.is_some()) + { return Err(PersistenceError::backend_with_kind( crate::changeset::PersistenceErrorKind::Transient, "injected store failure", @@ -2159,6 +2281,8 @@ mod tests { } fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + self.flushes + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); if *self.fail_flushes.lock().unwrap() { return Err(PersistenceError::backend_with_kind( PersistenceErrorKind::Transient, @@ -12505,8 +12629,8 @@ mod tests { } } - /// A failed `build_signed` must return the consumed payment address to - /// the pool. Without the rollback every failed build (insufficient + /// A failed `build_signed` must release the payment address's + /// reservation. Without the release every failed build (insufficient /// funds, a refusing signer) permanently advances the next index by one: /// enough failures before one successful payment put that payment past /// any gap-limit walk a restore-from-seed can perform, and sent-payment @@ -12681,10 +12805,10 @@ mod tests { ); } - /// A definitively rejected broadcast must return the consumed payment - /// address to the pool AND persist the revert — unlike a failed build, - /// the used flip was already persisted before the broadcast attempt, so - /// an in-memory revert alone would be undone by the next relaunch. + /// A definitively rejected broadcast must release the payment address's + /// reservation AND persist the release — unlike a failed build, the + /// reservation was already persisted before the broadcast attempt, and + /// a relaunch on the FFI host reads a stored reservation back as used. #[tokio::test] async fn send_payment_rejected_broadcast_returns_the_address_to_the_pool() { use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; @@ -12696,7 +12820,7 @@ mod tests { let iw = wallet_arc.identity(); // Fund the wallet so build + sign succeed and the send reaches the - // broadcast (and its preceding used-flip persist). + // broadcast (and its preceding reservation persist). fund_bip44_account_0(&manager, wallet_id, 0xB7, 120_000).await; let seed = Mnemonic::from_phrase(TEST_MNEMONIC) @@ -12776,19 +12900,20 @@ mod tests { )) .count() >= 2, - "both the pre-broadcast flip and the post-rejection revert must persist" + "both the pre-broadcast reservation and the post-rejection release must persist" ); } - /// The `send_payment` used-flag flip persist must run only AFTER the - /// wallet-manager write guard is released (and before the broadcast). + /// Every `send_payment` contact-pool persist — the reservation before the + /// broadcast and its settlement after — must run only AFTER the + /// wallet-manager write guard is released. /// /// The host persistence callback is synchronous, so holding the manager's /// write lock across it would stall every other wallet accessor for the /// host write's duration, and a host store that re-entered a manager API - /// would deadlock the non-reentrant lock. This pins that the used-flip - /// changeset is persisted AND that the write lock was already released - /// when it was — a `try_read` on the shared manager succeeds iff no writer + /// would deadlock the non-reentrant lock. This pins that the contact + /// pool is persisted AND that the write lock was already released each + /// time it was — a `try_read` on the shared manager succeeds iff no writer /// holds it. Against the pre-fix ordering (store inside the write-guarded /// block) the lock-released assertion fails; with the persist deferred /// until after the guard drops it holds. @@ -12797,8 +12922,8 @@ mod tests { use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; use key_wallet::account::AccountType; - // Probe persister: on the used-flip store (the changeset carrying an - // external account's address pools), records whether the shared + // Probe persister: on every contact-pool store (a changeset carrying + // an external account's address pools), records whether the shared // wallet-manager write lock was already released — a `try_read` // succeeds iff no writer holds it. A `Weak` back-reference avoids a // persister <-> manager cycle. @@ -12807,8 +12932,8 @@ mod tests { >; struct LockProbePersister { manager: Mutex>>, - /// `Some(true)`: an external-pool store was seen with the write - /// lock released; `Some(false)`: seen but the lock was still held; + /// `Some(true)`: every external-pool store seen ran with the write + /// lock released; `Some(false)`: one ran with the lock still held; /// `None`: never seen. external_pool_store_unlocked: Mutex>, } @@ -12832,7 +12957,8 @@ mod tests { .and_then(|w| w.upgrade()) .map(|m| m.try_read().is_ok()) .unwrap_or(false); - *self.external_pool_store_unlocked.lock().unwrap() = Some(unlocked); + let mut seen = self.external_pool_store_unlocked.lock().unwrap(); + *seen = Some(seen.unwrap_or(true) && unlocked); } Ok(()) } @@ -12913,8 +13039,9 @@ mod tests { *probe.external_pool_store_unlocked.lock().unwrap() = None; // Fund BIP-44 account 0 so the send builds, signs, and broadcasts — - // reaching the used-flip persist, which now fires only on the path to - // broadcast (a funding-build failure returns before it). + // reaching the reservation persist, which fires only on the path to + // broadcast (a funding-build failure returns before it), and the + // settlement persist after it. fund_bip44_account_0(&manager, wallet_id, 0xA1, 60_000).await; let seed = Mnemonic::from_phrase(TEST_MNEMONIC) @@ -12930,15 +13057,14 @@ mod tests { .await .expect("funded + signable send must succeed through the accepting broadcaster"); - // The used-flip changeset was persisted (Some(_)) AND the store ran - // after the write guard was released (Some(true)). Some(false) means - // it still ran under the held write guard (the pre-fix ordering); - // None means the flip was never persisted. + // The contact pool was persisted (Some(_)) AND every store ran after + // the write guard was released (Some(true)). Some(false) means one + // ran under the held write guard; None means none was persisted. let observed = *probe.external_pool_store_unlocked.lock().unwrap(); assert_eq!( observed, Some(true), - "send_payment must persist the external-account used flip AFTER \ + "send_payment must persist the external-account pool AFTER \ releasing the wallet-manager write lock (observed: {observed:?})" ); } @@ -13377,19 +13503,7 @@ mod tests { fn with_accepting_broadcaster( real: &crate::wallet::identity::IdentityWallet, ) -> crate::wallet::identity::IdentityWallet { - crate::wallet::identity::IdentityWallet { - sdk: Arc::clone(&real.sdk), - wallet_manager: Arc::clone(&real.wallet_manager), - wallet_id: real.wallet_id, - asset_locks: Arc::clone(&real.asset_locks), - persister: real.persister.clone(), - sync_fault: Arc::clone(&real.sync_fault), - durable_cursors: Arc::clone(&real.durable_cursors), - broadcaster: Arc::new(AcceptingBroadcaster), - sdk_writer: Arc::clone(&real.sdk_writer), - dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), - dpns_sync_progress: Arc::clone(&real.dpns_sync_progress), - } + with_identity_broadcaster(real, AcceptingBroadcaster) } /// Broadcaster stub that definitively rejects every transaction, for the @@ -13414,6 +13528,14 @@ mod tests { fn with_rejecting_broadcaster( real: &crate::wallet::identity::IdentityWallet, ) -> crate::wallet::identity::IdentityWallet { + with_identity_broadcaster(real, RejectingBroadcaster) + } + + /// `real` with `broadcaster` in place of its own. + fn with_identity_broadcaster( + real: &crate::wallet::identity::IdentityWallet, + broadcaster: B, + ) -> crate::wallet::identity::IdentityWallet { crate::wallet::identity::IdentityWallet { sdk: Arc::clone(&real.sdk), wallet_manager: Arc::clone(&real.wallet_manager), @@ -13422,7 +13544,7 @@ mod tests { persister: real.persister.clone(), sync_fault: Arc::clone(&real.sync_fault), durable_cursors: Arc::clone(&real.durable_cursors), - broadcaster: Arc::new(RejectingBroadcaster), + broadcaster: Arc::new(broadcaster), sdk_writer: Arc::clone(&real.sdk_writer), dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), dpns_sync_progress: Arc::clone(&real.dpns_sync_progress), @@ -13662,4 +13784,2101 @@ mod tests { type-3 tx at 1 duff/byte when change is emitted" ); } + + // ----------------------------------------------------------------------- + // Prepared (two-step) contact payments + // ----------------------------------------------------------------------- + + /// The same wallet's core handle, re-specialized onto a test broadcaster: + /// shares the manager and the generation, so the age guard and the + /// in-broadcast fence see the real wallet. + fn core_with( + real: &crate::CoreWallet, + broadcaster: Bc, + ) -> crate::CoreWallet { + crate::CoreWallet::new( + Arc::clone(&real.sdk), + Arc::clone(&real.wallet_manager), + real.wallet_id, + Arc::new(broadcaster), + Arc::clone(real.generation()), + ) + } + + fn test_signer_and_provider() -> ( + SeedSigner, + crate::wallet::identity::network::contact_requests::SeedCryptoProvider, + ) { + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) + .expect("valid mnemonic") + .to_seed(""); + ( + SeedSigner::new(seed, Network::Testnet), + crate::wallet::identity::network::contact_requests::SeedCryptoProvider::from_seed( + seed, + Network::Testnet, + ), + ) + } + + async fn external_used_indices( + manager: &Arc>, + wallet_id: WalletId, + owner_id: Identifier, + contact_id: Identifier, + ) -> Vec { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let wm = wallet.identity().wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let account = info + .core_wallet + .accounts + .dashpay_external_accounts + .get(&DashpayAccountKey { + index: 0, + user_identity_id: owner_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }) + .expect("external account present"); + let pools = account.managed_account_type().address_pools(); + let mut used: Vec = pools + .first() + .expect("external pool") + .used_indices + .iter() + .copied() + .collect(); + used.sort_unstable(); + used + } + + fn persisted_external_pool_snapshots(persister: &RecordingPersister) -> usize { + persister + .stores + .lock() + .unwrap() + .iter() + .flat_map(|(_, changeset)| changeset.account_address_pools.iter()) + .filter(|entry| { + matches!( + entry.account_type, + key_wallet::account::AccountType::DashpayExternalAccount { .. } + ) + }) + .count() + } + + /// The script the payment output of `tx` pays — found by its amount, since + /// the builder does not keep the payment output first. + fn payment_script(tx: &dashcore::Transaction, amount_duffs: u64) -> dashcore::ScriptBuf { + let mut paying = tx + .output + .iter() + .filter(|output| output.value == amount_duffs); + let output = paying.next().expect("the payment output"); + assert!(paying.next().is_none(), "the amount picks one output"); + output.script_pubkey.clone() + } + + /// Broadcaster stub whose outcome is unknown: the transaction may have + /// reached the network. + struct MaybeSentBroadcaster; + + #[async_trait::async_trait] + impl crate::broadcaster::TransactionBroadcaster for MaybeSentBroadcaster { + async fn broadcast( + &self, + _transaction: &dashcore::Transaction, + ) -> Result { + Err(crate::broadcaster::BroadcastError::MaybeSent { + reason: "test timeout".to_string(), + }) + } + } + + /// Let the wallet observe, chain-locked, a payment to `address` made from + /// coins it does not hold — another device on the same seed paying the + /// contact — through key-wallet's own transaction checking. Then drop the + /// account's records, as the ChainLock eviction does without + /// `keep-finalized-transactions`, so only the address state is left as + /// evidence of the payment. + async fn observe_foreign_payment_then_evict( + manager: &Arc>, + wallet_id: WalletId, + owner_id: Identifier, + contact_id: Identifier, + address: &dashcore::Address, + txid_byte: u8, + ) { + use dashcore::hashes::Hash; + use key_wallet::transaction_checking::{ + BlockInfo, TransactionContext, WalletTransactionChecker, + }; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let mut wm = wallet.identity().wallet_manager.write().await; + let (core_wallet, info) = wm + .get_wallet_mut_and_info_mut(&wallet_id) + .expect("wallet and info"); + let tx = dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: dashcore::OutPoint::new( + dashcore::Txid::from_slice(&[txid_byte; 32]).expect("txid"), + 0, + ), + ..Default::default() + }], + output: vec![dashcore::TxOut { + value: 30_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let result = info + .core_wallet + .check_core_transaction( + &tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 200, + dashcore::BlockHash::all_zeros(), + 1_700_000_000, + )), + core_wallet, + true, + true, + ) + .await; + assert!( + result.is_relevant, + "a payment to a contact address is ours to see" + ); + let account = info + .core_wallet + .accounts + .dashpay_external_accounts + .get_mut(&DashpayAccountKey { + index: 0, + user_identity_id: owner_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }) + .expect("external account"); + account.transactions_mut().clear(); + } + + /// Prepare reports the exact fee before anything is sent and leaves the + /// address pool alone; the send broadcasts that very transaction, marks + /// the address used and records the payment. + #[tokio::test] + async fn should_send_the_transaction_whose_fee_the_prepared_payment_showed() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xD1, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let core = core_with(wallet_arc.core(), AcceptingBroadcaster); + + persister.stores.lock().unwrap().clear(); + let prepared = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("a funded wallet prepares the payment"); + let fee = prepared.fee_duffs(); + let prepared_txid = prepared.txid(); + assert!(fee > 0, "a signed transaction pays a fee"); + assert_eq!(prepared.total_duffs(), 50_000 + fee); + assert_eq!( + persisted_external_pool_snapshots(&persister), + 0, + "nothing is persisted while the payment only waits for confirmation" + ); + assert!( + external_used_indices(&manager, wallet_id, owner_id, contact_id) + .await + .is_empty(), + "a prepared payment does not claim its address" + ); + + let (txid, entry, sent_fee) = iw + .dashpay() + .send_prepared_payment(prepared) + .await + .expect("the accepting broadcaster takes it"); + assert_eq!(txid, prepared_txid, "the shown transaction is the sent one"); + assert_eq!( + sent_fee, fee, + "the fee cannot move between confirm and send" + ); + assert_eq!(entry.amount_duffs, 50_000); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0] + ); + assert_eq!( + persisted_external_pool_snapshots(&persister), + 2, + "the reservation is stored before the broadcast, the used flag after it" + ); + let recorded = { + let wm = iw.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .and_then(|info| info.identity_manager.managed_identity(&owner_id)) + .map(|identity| identity.dashpay().payments.contains_key(&txid.to_string())) + }; + assert_eq!( + recorded, + Some(true), + "the payment is recorded on the sender" + ); + } + + /// Abandoning frees the inputs and persists nothing — neither does a + /// prepare that cannot be funded — so no backend ever holds a used flag + /// for an address that was never paid. The next prepare spends the same + /// coin to the same, never exposed, address. + #[tokio::test] + async fn should_free_inputs_and_persist_nothing_when_a_prepared_payment_is_abandoned() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + // One coin: while it is reserved nothing else can fund a payment. + fund_bip44_account_0(&manager, wallet_id, 0xD2, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let core = core_with(wallet_arc.core(), AcceptingBroadcaster); + + persister.stores.lock().unwrap().clear(); + let first = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("first prepare"); + let first_output = payment_script(first.transaction(), 100_000); + + let blocked = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await; + assert!( + blocked.is_err(), + "the only coin is reserved by the waiting payment" + ); + + iw.dashpay().abandon_prepared_payment(first).await; + assert!( + external_used_indices(&manager, wallet_id, owner_id, contact_id) + .await + .is_empty() + ); + assert_eq!( + persisted_external_pool_snapshots(&persister), + 0, + "a preview, a failed prepare and an abandon write no address state" + ); + + let again = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("abandon released the reservation"); + assert_eq!( + payment_script(again.transaction(), 100_000), + first_output, + "the never-broadcast address is handed out again" + ); + iw.dashpay().abandon_prepared_payment(again).await; + } + + /// Two payments prepared to one contact at once pay the same address; the + /// first one sent takes it, and the second is refused with its inputs + /// released instead of paying that address twice. + #[tokio::test] + async fn should_refuse_a_second_payment_prepared_to_the_same_address() { + let (manager, _persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xD6, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let core = core_with(wallet_arc.core(), AcceptingBroadcaster); + + let first = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 30_000, + None, + &signer, + &provider, + ) + .await + .expect("first prepare"); + iw.dashpay() + .send_prepared_payment(first) + .await + .expect("first send"); + // The first payment's change is still unconfirmed and its input is + // spent, so the second needs a coin of its own. + fund_bip44_account_0(&manager, wallet_id, 0xD7, 120_000).await; + let second = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 30_000, + None, + &signer, + &provider, + ) + .await + .expect("second prepare"); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0], + "the first send took the first address" + ); + + // A third prepared before the second is sent targets the same address + // as the second. + fund_bip44_account_0(&manager, wallet_id, 0xD8, 120_000).await; + let third = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 30_000, + None, + &signer, + &provider, + ) + .await + .expect("third prepare"); + assert_eq!( + payment_script(second.transaction(), 30_000), + payment_script(third.transaction(), 30_000), + "nothing claims the address before the send" + ); + iw.dashpay() + .send_prepared_payment(second) + .await + .expect("second send"); + let err = iw + .dashpay() + .send_prepared_payment(third) + .await + .expect_err("the address the second payment took must not be paid again"); + assert!( + matches!(err, PlatformWalletError::PreparedPaymentAddressTaken(_)), + "got {err:?}" + ); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id) + .await + .len(), + 2, + "the refused payment marked nothing" + ); + let retry = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 30_000, + None, + &signer, + &provider, + ) + .await + .expect("the refusal released its inputs"); + iw.dashpay().abandon_prepared_payment(retry).await; + } + + /// Another device on the same seed pays the contact at the waiting + /// payment's address, and the ChainLock then evicts that record. The + /// address state key-wallet set when it saw the payment is what is left, + /// and it is enough: the send is refused, and an abandon leaves the + /// address used. + #[tokio::test] + async fn should_keep_an_address_paid_meanwhile_used_after_its_record_is_evicted() { + let (manager, _persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xD5, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let core = core_with(wallet_arc.core(), AcceptingBroadcaster); + let address_of = |prepared: &crate::PreparedDashPayPayment| { + dashcore::Address::from_script( + &payment_script(prepared.transaction(), 50_000), + Network::Testnet, + ) + .expect("a P2PKH payment output") + }; + + // Send refused. + let prepared = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + let paid = address_of(&prepared); + observe_foreign_payment_then_evict(&manager, wallet_id, owner_id, contact_id, &paid, 0xE1) + .await; + let err = iw + .dashpay() + .send_prepared_payment(prepared) + .await + .expect_err("a second payment to an address already paid must not go out"); + assert!( + matches!(err, PlatformWalletError::PreparedPaymentAddressTaken(_)), + "got {err:?}" + ); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0] + ); + + // Abandon leaves it used. + let prepared = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("the refused send released its inputs"); + let paid_next = address_of(&prepared); + assert_ne!(paid_next, paid, "the next payment goes to a fresh address"); + observe_foreign_payment_then_evict( + &manager, wallet_id, owner_id, contact_id, &paid_next, 0xE2, + ) + .await; + iw.dashpay().abandon_prepared_payment(prepared).await; + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id) + .await + .len(), + 2, + "an address paid on-chain is never handed out again" + ); + } + + /// A resident record pays the waiting payment's address while the pool + /// still shows it available. The send is refused and marks it used, so + /// the next prepare moves on instead of being refused the same way. + #[tokio::test] + async fn should_refuse_and_mark_an_available_address_a_record_pays() { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let (manager, _persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xDB, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let core = core_with(wallet_arc.core(), AcceptingBroadcaster); + + let prepared = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + let paid_script = payment_script(prepared.transaction(), 50_000); + let paid_address = dashcore::Address::from_script(&paid_script, Network::Testnet) + .expect("a P2PKH payment output"); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + let account = info + .core_wallet + .accounts + .dashpay_external_accounts + .get_mut(&DashpayAccountKey { + index: 0, + user_identity_id: owner_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }) + .expect("external account"); + // A record only, the address left available — the state a revert + // without the record check used to leave behind. + let record = tx_record_with_outputs( + key_wallet::transaction_checking::TransactionContext::Mempool, + vec![( + paid_address, + 30_000, + key_wallet::managed_account::transaction_record::OutputRole::Sent, + )], + ); + account.transactions_mut().insert(record.txid, record); + } + + let err = iw + .dashpay() + .send_prepared_payment(prepared) + .await + .expect_err("an address a record pays must not be paid again"); + assert!( + matches!(err, PlatformWalletError::PreparedPaymentAddressTaken(_)), + "got {err:?}" + ); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0], + "the refusal records the payment it found" + ); + let next = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("the refusal released its inputs"); + assert_ne!( + payment_script(next.transaction(), 50_000), + paid_script, + "the next payment goes to a fresh address" + ); + iw.dashpay().abandon_prepared_payment(next).await; + } + + /// A definitive rejection releases the inputs and returns the address, + /// persisted; the retry takes the same address. + #[tokio::test] + async fn should_return_inputs_and_address_when_a_prepared_payment_is_rejected() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xD3, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + + let rejecting = core_with(wallet_arc.core(), RejectingBroadcaster); + let prepared = iw + .dashpay() + .prepare_payment( + &rejecting, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + let rejected_output = payment_script(prepared.transaction(), 100_000); + persister.stores.lock().unwrap().clear(); + let err = iw + .dashpay() + .send_prepared_payment(prepared) + .await + .expect_err("the rejecting broadcaster fails the send"); + assert!( + matches!(err, PlatformWalletError::TransactionBroadcast(_)), + "got {err:?}" + ); + assert!( + external_used_indices(&manager, wallet_id, owner_id, contact_id) + .await + .is_empty(), + "nothing reached the network, so the address is free again" + ); + assert_eq!( + persisted_external_pool_snapshots(&persister), + 2, + "the reservation and its release are both persisted" + ); + + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let retry = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("the rejection released the only coin"); + assert_eq!( + payment_script(retry.transaction(), 100_000), + rejected_output, + "the retry reuses the address, so failed attempts open no gap" + ); + iw.dashpay() + .send_prepared_payment(retry) + .await + .expect("the retry goes out"); + } + + /// An ambiguous broadcast keeps both the address and the input fence: the + /// transaction may be on the network. + #[tokio::test] + async fn should_keep_address_and_inputs_when_a_prepared_payment_maybe_sent() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + // One coin, so a second prepare can only succeed by reselecting it. + fund_bip44_account_0(&manager, wallet_id, 0xD9, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let core = core_with(wallet_arc.core(), MaybeSentBroadcaster); + + let prepared = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + persister.stores.lock().unwrap().clear(); + let err = iw + .dashpay() + .send_prepared_payment(prepared) + .await + .expect_err("an unknown outcome is not a success"); + assert!( + matches!(err, PlatformWalletError::TransactionBroadcastUnconfirmed(_)), + "got {err:?}" + ); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0], + "the address may already be paid" + ); + assert_eq!( + persisted_external_pool_snapshots(&persister), + 2, + "the reservation, then the used flag — no rollback" + ); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let again = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await; + assert!( + matches!( + again, + Err(PlatformWalletError::CorePooledInsufficientFunds { + available: Some(0), + .. + }) + ), + "the possibly-sent transaction's only input stays fenced" + ); + } + + /// A send whose reservation cannot be stored sends nothing, releases its + /// inputs and returns its address, so the retry pays the same address. + #[tokio::test] + async fn should_return_the_address_when_a_prepared_reservation_cannot_be_stored() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + // One coin, so the retry proves the inputs were released. + fund_bip44_account_0(&manager, wallet_id, 0xDA, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let core = core_with(wallet_arc.core(), AcceptingBroadcaster); + + let prepared = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + let first_output = payment_script(prepared.transaction(), 100_000); + *persister.fail_stores.lock().unwrap() = true; + let err = iw + .dashpay() + .send_prepared_payment(prepared) + .await + .expect_err("an unstored reservation must not be broadcast"); + *persister.fail_stores.lock().unwrap() = false; + assert!( + matches!(err, PlatformWalletError::PersisterStore(_)), + "got {err:?}" + ); + assert!( + external_used_indices(&manager, wallet_id, owner_id, contact_id) + .await + .is_empty() + ); + let retry = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("the failed send released the only coin"); + assert_eq!(payment_script(retry.transaction(), 100_000), first_output); + iw.dashpay().abandon_prepared_payment(retry).await; + } + + /// A payment held on the confirmation screen past the reservation age + /// bound is refused before its address is marked, and gives back its + /// inputs so the host can prepare it again. + #[tokio::test] + async fn should_refuse_a_stale_prepared_payment_before_the_mark() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xD4, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let core = core_with(wallet_arc.core(), RejectingBroadcaster); + + let prepared = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + let height = info.core_wallet.last_processed_height(); + info.core_wallet.update_last_processed_height( + height + crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS, + ); + } + persister.stores.lock().unwrap().clear(); + let err = iw + .dashpay() + .send_prepared_payment(prepared) + .await + .expect_err("an aged reservation must not be broadcast"); + // The rejecting broadcaster would have answered TransactionBroadcast: + // StaleReservation proves the send stopped before it. + assert!( + matches!(err, PlatformWalletError::StaleReservation), + "got {err:?}" + ); + assert!( + external_used_indices(&manager, wallet_id, owner_id, contact_id) + .await + .is_empty() + ); + assert_eq!(persisted_external_pool_snapshots(&persister), 0); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let again = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("the refusal released the coin for a fresh prepare"); + iw.dashpay().abandon_prepared_payment(again).await; + } + + /// Rejects every transaction, after letting the wallet observe — and + /// evict — another device's payment to the contact address the + /// transaction pays: the observation lands while the send is in flight. + struct ForeignPaymentThenRejectBroadcaster { + manager: Arc>, + wallet_id: WalletId, + owner_id: Identifier, + contact_id: Identifier, + address: dashcore::Address, + } + + #[async_trait::async_trait] + impl crate::broadcaster::TransactionBroadcaster for ForeignPaymentThenRejectBroadcaster { + async fn broadcast( + &self, + _transaction: &dashcore::Transaction, + ) -> Result { + observe_foreign_payment_then_evict( + &self.manager, + self.wallet_id, + self.owner_id, + self.contact_id, + &self.address, + 0xE3, + ) + .await; + Err(crate::broadcaster::BroadcastError::Rejected { + reason: "test rejection".to_string(), + }) + } + } + + /// Accepts every transaction, recording what was durable when it ran: the + /// flush count and whether the last stored contact pool had the payment's + /// address reserved. + struct DurabilityProbeBroadcaster { + persister: Arc, + seen: Mutex>, + } + + #[async_trait::async_trait] + impl crate::broadcaster::TransactionBroadcaster for DurabilityProbeBroadcaster { + async fn broadcast( + &self, + transaction: &dashcore::Transaction, + ) -> Result { + use key_wallet::managed_account::address_pool::AddressState; + + let flushes = self + .persister + .flushes + .load(std::sync::atomic::Ordering::SeqCst); + let reserved = self + .persister + .stores + .lock() + .unwrap() + .iter() + .rev() + .flat_map(|(_, changeset)| changeset.account_address_pools.iter()) + .find(|entry| { + matches!( + entry.account_type, + key_wallet::account::AccountType::DashpayExternalAccount { .. } + ) + }) + .is_some_and(|entry| { + entry.addresses.iter().any(|info| { + matches!(info.state, AddressState::Reserved { .. }) + && transaction + .output + .iter() + .any(|output| output.script_pubkey == info.script_pubkey) + }) + }); + *self.seen.lock().unwrap() = Some((flushes, reserved)); + Ok(transaction.txid()) + } + } + + /// The reservation is stored, and flushed on a backend that buffers, + /// before the broadcaster runs. + #[tokio::test] + async fn should_durably_reserve_a_prepared_payment_before_the_broadcast() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xDC, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + *persister.buffered.lock().unwrap() = true; + let probe = Arc::new(DurabilityProbeBroadcaster { + persister: Arc::clone(&persister), + seen: Mutex::new(None), + }); + let core = crate::CoreWallet::new( + Arc::clone(&wallet_arc.core().sdk), + Arc::clone(&wallet_arc.core().wallet_manager), + wallet_arc.core().wallet_id, + Arc::clone(&probe), + Arc::clone(wallet_arc.core().generation()), + ); + + let prepared = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + let flushes_before = persister.flushes.load(std::sync::atomic::Ordering::SeqCst); + iw.dashpay() + .send_prepared_payment(prepared) + .await + .expect("send"); + let (flushes_at_broadcast, reserved_at_broadcast) = + probe.seen.lock().unwrap().expect("the broadcaster ran"); + assert!( + flushes_at_broadcast > flushes_before, + "a buffering backend is flushed before the network sees the transaction" + ); + assert!( + reserved_at_broadcast, + "the stored pool holds the address reserved when the broadcaster runs" + ); + } + + /// A reservation that a buffering backend accepts but cannot flush is + /// not on disk, so nothing is sent and the inputs come back. The error + /// is the flush's, kind kept: a transient flush is retried with another + /// flush, which the store-side reissue attestation says nothing about. + #[tokio::test] + async fn should_send_nothing_when_a_prepared_reservation_cannot_be_flushed() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + // One coin, so the retry proves the inputs were released. + fund_bip44_account_0(&manager, wallet_id, 0xDD, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let probe = Arc::new(DurabilityProbeBroadcaster { + persister: Arc::clone(&persister), + seen: Mutex::new(None), + }); + let core = crate::CoreWallet::new( + Arc::clone(&wallet_arc.core().sdk), + Arc::clone(&wallet_arc.core().wallet_manager), + wallet_arc.core().wallet_id, + Arc::clone(&probe), + Arc::clone(wallet_arc.core().generation()), + ); + + let prepared = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + let first_output = payment_script(prepared.transaction(), 100_000); + *persister.buffered.lock().unwrap() = true; + *persister.fail_flushes.lock().unwrap() = true; + let err = iw + .dashpay() + .send_prepared_payment(prepared) + .await + .expect_err("an unflushed reservation must not be broadcast"); + *persister.fail_flushes.lock().unwrap() = false; + assert!( + matches!( + &err, + PlatformWalletError::PersisterFlush(source) + if source.kind() == Some(PersistenceErrorKind::Transient) + ), + "got {err:?}" + ); + assert!( + probe.seen.lock().unwrap().is_none(), + "the broadcaster never ran" + ); + assert!( + external_used_indices(&manager, wallet_id, owner_id, contact_id) + .await + .is_empty() + ); + let retry = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("the failed send released the only coin"); + assert_eq!( + payment_script(retry.transaction(), 100_000), + first_output, + "the failed send gave its address back" + ); + iw.dashpay().abandon_prepared_payment(retry).await; + } + + /// Broadcaster that, while it holds the transaction, checks whether a + /// teardown of the funding generation could start, then accepts it. + struct TeardownProbeBroadcaster { + generation: Arc, + teardown_blocked: Arc>>, + } + + #[async_trait::async_trait] + impl crate::broadcaster::TransactionBroadcaster for TeardownProbeBroadcaster { + async fn broadcast( + &self, + transaction: &dashcore::Transaction, + ) -> Result { + let blocked = tokio::time::timeout( + std::time::Duration::from_millis(200), + self.generation.teardown_guard(), + ) + .await + .is_err(); + *self.teardown_blocked.lock().unwrap() = Some(blocked); + Ok(transaction.txid()) + } + } + + /// The native send holds the generation's lifecycle gate through the + /// broadcast, so a wallet removal cannot complete while its payment is on + /// the way to the network — without relying on a host wrapper to take it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_hold_the_lifecycle_gate_through_a_prepared_broadcast() { + let (manager, _persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xDE, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let teardown_blocked = Arc::new(Mutex::new(None)); + let core = core_with( + wallet_arc.core(), + TeardownProbeBroadcaster { + generation: Arc::clone(wallet_arc.core().generation()), + teardown_blocked: Arc::clone(&teardown_blocked), + }, + ); + + let prepared = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + iw.dashpay() + .send_prepared_payment(prepared) + .await + .expect("send"); + assert_eq!( + *teardown_blocked.lock().unwrap(), + Some(true), + "a teardown could start while the payment was being broadcast" + ); + } + + /// A payment whose wallet was removed after it was prepared is refused + /// by the liveness check as `WalletRemovedBeforeSend` — the error hosts + /// read as "nothing was sent" — and never reaches the broadcaster. + #[tokio::test] + async fn should_refuse_a_removed_wallets_prepared_payment_before_the_network() { + let (manager, _persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + fund_bip44_account_0(&manager, wallet_id, 0xDF, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + // Rejecting, so a send that reached the broadcaster would surface as + // `TransactionBroadcast` rather than the refusal. + let core = core_with(wallet_arc.core(), RejectingBroadcaster); + let prepared = wallet_arc + .identity() + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + + manager.remove_wallet(&wallet_id).await.expect("remove"); + let err = wallet_arc + .identity() + .dashpay() + .send_prepared_payment(prepared) + .await + .expect_err("a removed wallet's payment is not sent"); + assert!( + matches!(err, PlatformWalletError::WalletRemovedBeforeSend(_)), + "got {err:?}" + ); + } + + /// A prepared send parked inside the host store of its reservation holds + /// the contact-payment gate, so a competing reservation for the same + /// contact cannot store its snapshot first and then be overwritten by + /// the older one. Removing the gate from `send_prepared_payment` lets the + /// competing reservation complete while the send is parked. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_not_persist_a_reserved_payout_address_ahead_of_a_paused_prepared_send() { + 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([0xAE; 32]); + let contact = Identifier::from([0xBE; 32]); + install_external_account(&manager, wallet_id, owner, contact).await; + fund_bip44_account_0(&manager, wallet_id, 0xE0, 120_000).await; + let wallet = manager.get_wallet(&wallet_id).await.unwrap(); + let (signer, provider) = test_signer_and_provider(); + let core = core_with(wallet.core(), AcceptingBroadcaster); + let prepared = wallet + .identity() + .dashpay() + .prepare_payment(&core, &owner, &contact, 50_000, None, &signer, &provider) + .await + .expect("prepare"); + assert!( + persister.external_pool_snapshots().is_empty(), + "prepare stores no pool" + ); + + let sending = Arc::clone(&wallet); + let mut send = tokio::spawn(async move { + sending + .identity() + .dashpay() + .send_prepared_payment(prepared) + .await + }); + let entered = Arc::clone(&persister); + assert!( + tokio::task::spawn_blocking(move || PausingPoolPersister::wait_for(&entered.entered)) + .await + .unwrap(), + "the send never reached the host store of its reservation" + ); + + let reserving = Arc::clone(&wallet); + let mut reserve = tokio::spawn(async move { + let provider = SeedCryptoProvider::from_seed( + Mnemonic::from_phrase(TEST_MNEMONIC).unwrap().to_seed(""), + Network::Testnet, + ); + reserving + .identity() + .dashpay() + .reserve_payment_address(&owner, &contact, &provider) + .await + }); + let raced = tokio::time::timeout(std::time::Duration::from_millis(500), &mut reserve).await; + let leaked = persister.external_pool_snapshots(); + // Unpark before asserting, 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 send's reservation store was \ + parked: {raced:?}" + ); + assert!( + leaked.is_empty(), + "nothing may reach the host while the reservation store is parked" + ); + + (&mut send).await.unwrap().expect("send"); + let reserved = reserve.await.unwrap().expect("reserve"); + let snapshots = persister.external_pool_snapshots(); + let taken = |snapshot: &[key_wallet::AddressInfo]| { + snapshot + .iter() + .filter(|info| !info.is_available()) + .map(|info| info.address.clone()) + .collect::>() + }; + let last = taken(snapshots.last().expect("snapshots")); + assert_eq!( + last.len(), + 2, + "both payments' addresses persist as taken: {last:?}" + ); + assert!(last.contains(&reserved)); + } + + /// Another device pays the contact at this payment's address while the + /// send is in flight, and the ChainLock evicts that record before the + /// network rejects this send. Releasing the reservation must not erase + /// the observed payment: the address stays used. + #[tokio::test] + async fn should_keep_an_address_paid_during_a_prepared_send_after_a_rejection() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xDD, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + + // Learn the address the payment will take, then prepare it for real + // on a core whose broadcaster stages the foreign payment. + let probe = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("probe prepare"); + let address = dashcore::Address::from_script( + &payment_script(probe.transaction(), 50_000), + Network::Testnet, + ) + .expect("a P2PKH payment output"); + iw.dashpay().abandon_prepared_payment(probe).await; + + let racing = core_with( + wallet_arc.core(), + ForeignPaymentThenRejectBroadcaster { + manager: Arc::clone(&manager), + wallet_id, + owner_id, + contact_id, + address: address.clone(), + }, + ); + let prepared = iw + .dashpay() + .prepare_payment( + &racing, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + let err = iw + .dashpay() + .send_prepared_payment(prepared) + .await + .expect_err("the broadcaster rejects"); + assert!( + matches!(err, PlatformWalletError::TransactionBroadcast(_)), + "got {err:?}" + ); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0], + "the release only drops the claim; the observed payment keeps the address used" + ); + // The observation stores no contact pool, so settle must: the last + // stored snapshot shows the address used. + let stored_used = persister + .stores + .lock() + .unwrap() + .iter() + .rev() + .flat_map(|(_, changeset)| changeset.account_address_pools.iter()) + .find(|entry| { + matches!( + entry.account_type, + key_wallet::account::AccountType::DashpayExternalAccount { .. } + ) + }) + .is_some_and(|entry| { + entry + .addresses + .iter() + .any(|info| info.script_pubkey == address.script_pubkey() && info.is_used()) + }); + assert!(stored_used, "the observed payment's used flag reaches disk"); + let next = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("the rejection released the inputs"); + assert_ne!( + payment_script(next.transaction(), 50_000), + address.script_pubkey(), + "the paid address is not handed out again" + ); + iw.dashpay().abandon_prepared_payment(next).await; + } + + /// The single-shot send takes the address the same way: reserved across + /// the broadcast, so a rejection releases only the reservation. A payment + /// another device made there meanwhile, its record evicted by the + /// ChainLock, keeps the address used — in memory and on disk — and the + /// next payment goes elsewhere. + #[tokio::test] + async fn should_keep_an_address_paid_during_send_payment_after_a_rejection() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xDE, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + + let address = next_send_address(iw, &accepting, owner_id, contact_id).await; + + let racing = with_identity_broadcaster( + iw, + ForeignPaymentThenRejectBroadcaster { + manager: Arc::clone(&manager), + wallet_id, + owner_id, + contact_id, + address: address.clone(), + }, + ); + let err = racing + .dashpay() + .send_payment(&owner_id, &contact_id, 50_000, None, &signer, &provider) + .await + .expect_err("the broadcaster rejects"); + assert!( + matches!(err, PlatformWalletError::TransactionBroadcast(_)), + "got {err:?}" + ); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0], + "the release only drops the reservation; the observed payment keeps the address used" + ); + let stored_used = persister + .stores + .lock() + .unwrap() + .iter() + .rev() + .flat_map(|(_, changeset)| changeset.account_address_pools.iter()) + .find(|entry| { + matches!( + entry.account_type, + key_wallet::account::AccountType::DashpayExternalAccount { .. } + ) + }) + .is_some_and(|entry| { + entry + .addresses + .iter() + .any(|info| info.script_pubkey == address.script_pubkey() && info.is_used()) + }); + assert!(stored_used, "the observed payment's used flag reaches disk"); + let next = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("the rejection released the inputs"); + assert_ne!( + payment_script(next.transaction(), 50_000), + address.script_pubkey(), + "the paid address is not handed out again" + ); + iw.dashpay().abandon_prepared_payment(next).await; + } + + /// The address `send_payment` would pay, learned from a prepare that is + /// abandoned at once. + async fn next_send_address( + iw: &crate::wallet::identity::IdentityWallet, + core: &crate::wallet::core::CoreWallet, + owner_id: Identifier, + contact_id: Identifier, + ) -> dashcore::Address { + let (signer, provider) = test_signer_and_provider(); + let probe = iw + .dashpay() + .prepare_payment( + core, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("probe prepare"); + let address = dashcore::Address::from_script( + &payment_script(probe.transaction(), 50_000), + Network::Testnet, + ) + .expect("a P2PKH payment output"); + iw.dashpay().abandon_prepared_payment(probe).await; + address + } + + /// Make resident a mempool record paying `paid` on the contact's external + /// account, leaving the address available — another device's payment the + /// pool never marked. + async fn plant_resident_contact_payment( + iw: &crate::wallet::identity::IdentityWallet, + wallet_id: WalletId, + owner_id: Identifier, + contact_id: Identifier, + paid: &dashcore::Address, + ) { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + let account = info + .core_wallet + .accounts + .dashpay_external_accounts + .get_mut(&DashpayAccountKey { + index: 0, + user_identity_id: owner_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }) + .expect("external account"); + let record = tx_record_with_outputs( + key_wallet::transaction_checking::TransactionContext::Mempool, + vec![( + paid.clone(), + 30_000, + key_wallet::managed_account::transaction_record::OutputRole::Sent, + )], + ); + account.transactions_mut().insert(record.txid, record); + } + + /// A single-shot send whose reservation cannot be stored sends nothing + /// and gives back its inputs and its address, so the immediate retry + /// pays from the same coin to the same address. + #[tokio::test] + async fn should_release_inputs_and_address_when_send_payment_cannot_store_its_reservation() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + // One coin, so the retry proves the inputs were released. + fund_bip44_account_0(&manager, wallet_id, 0xDF, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let address = next_send_address(iw, &accepting, owner_id, contact_id).await; + + let iw_send = with_accepting_broadcaster(iw); + *persister.fail_stores.lock().unwrap() = true; + let err = iw_send + .dashpay() + .send_payment(&owner_id, &contact_id, 100_000, None, &signer, &provider) + .await + .expect_err("an unstored reservation must not be broadcast"); + *persister.fail_stores.lock().unwrap() = false; + assert!( + matches!(err, PlatformWalletError::PersisterStore(_)), + "got {err:?}" + ); + assert_eq!( + next_send_address(iw, &accepting, owner_id, contact_id).await, + address, + "the address is released" + ); + + iw_send + .dashpay() + .send_payment(&owner_id, &contact_id, 100_000, None, &signer, &provider) + .await + .expect("the failed send released the only coin"); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0], + "the retry paid the same address" + ); + } + + /// An address a resident record pays while the pool still shows it + /// available — another device on the same seed paid the contact there — + /// is marked used and skipped, not paid a second time. + #[tokio::test] + async fn should_skip_an_available_address_a_record_pays_in_send_payment() { + let (manager, _persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xE0, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let paid = next_send_address(iw, &accepting, owner_id, contact_id).await; + plant_resident_contact_payment(iw, wallet_id, owner_id, contact_id, &paid).await; + + let iw_send = with_accepting_broadcaster(iw); + iw_send + .dashpay() + .send_payment(&owner_id, &contact_id, 50_000, None, &signer, &provider) + .await + .expect("send"); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0, 1], + "the paid address is marked used and the payment takes the next one" + ); + } + + /// A prepare runs the same walk as `send_payment`: an address a resident + /// record pays is marked used and stored, and the payment takes the next + /// one, so the confirmation never shows an address the send would refuse. + #[tokio::test] + async fn should_skip_an_available_address_a_record_pays_in_prepare_payment() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xE4, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let paid = next_send_address(iw, &accepting, owner_id, contact_id).await; + plant_resident_contact_payment(iw, wallet_id, owner_id, contact_id, &paid).await; + persister.stores.lock().unwrap().clear(); + + let prepared = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + assert_ne!( + payment_script(prepared.transaction(), 50_000), + paid.script_pubkey(), + "the paid address is not put on the confirmation" + ); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0], + "the paid address is marked used" + ); + let stored_used = persister + .stores + .lock() + .unwrap() + .iter() + .flat_map(|(_, changeset)| changeset.account_address_pools.iter()) + .any(|entry| { + entry + .addresses + .iter() + .any(|info| info.script_pubkey == paid.script_pubkey() && info.is_used()) + }); + assert!(stored_used, "the address found paid reaches disk"); + iw.dashpay().abandon_prepared_payment(prepared).await; + } + + /// The contact external address at `index`, from the derived pool. + async fn external_address_at( + iw: &crate::wallet::identity::IdentityWallet, + wallet_id: WalletId, + owner_id: Identifier, + contact_id: Identifier, + index: u32, + ) -> dashcore::Address { + let wm = iw.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .expect("info") + .core_wallet + .accounts + .dashpay_external_accounts + .get(&DashpayAccountKey { + index: 0, + user_identity_id: owner_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }) + .expect("external account") + .managed_account_type() + .address_pools() + .first() + .expect("external pool") + .address_at_index(index) + .expect("derived index") + } + + /// Consecutive paid addresses are all marked in one walk, and the + /// payment takes the first unpaid one after them. + #[tokio::test] + async fn should_mark_every_consecutive_paid_address_before_preparing() { + let (manager, _persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xE7, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let _ = next_send_address(iw, &accepting, owner_id, contact_id).await; + for index in [0, 1] { + let paid = external_address_at(iw, wallet_id, owner_id, contact_id, index).await; + plant_resident_contact_payment(iw, wallet_id, owner_id, contact_id, &paid).await; + } + let third = external_address_at(iw, wallet_id, owner_id, contact_id, 2).await; + + let prepared = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + assert_eq!( + payment_script(prepared.transaction(), 50_000), + third.script_pubkey() + ); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0, 1] + ); + iw.dashpay().abandon_prepared_payment(prepared).await; + } + + /// A paid address past the next available one is not reached by the + /// walk: the next available address is still unpaid, so it is the one + /// paid and nothing is marked. + #[tokio::test] + async fn should_not_mark_a_paid_address_beyond_the_next_available_one() { + let (manager, _persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xE8, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let first = next_send_address(iw, &accepting, owner_id, contact_id).await; + let second = external_address_at(iw, wallet_id, owner_id, contact_id, 1).await; + plant_resident_contact_payment(iw, wallet_id, owner_id, contact_id, &second).await; + + let prepared = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + assert_eq!( + payment_script(prepared.transaction(), 50_000), + first.script_pubkey() + ); + assert!( + external_used_indices(&manager, wallet_id, owner_id, contact_id) + .await + .is_empty() + ); + iw.dashpay().abandon_prepared_payment(prepared).await; + } + + /// A prepare through the core handle of a removed wallet writes nothing + /// back for it: the found-paid store is gated on the generation. + #[tokio::test] + async fn should_refuse_to_prepare_through_a_removed_wallets_core_handle() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xE9, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let paid = next_send_address(iw, &accepting, owner_id, contact_id).await; + plant_resident_contact_payment(iw, wallet_id, owner_id, contact_id, &paid).await; + + manager.remove_wallet(&wallet_id).await.expect("remove"); + persister.stores.lock().unwrap().clear(); + let result = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await; + assert!( + result.is_err(), + "a removed wallet's payment is not prepared" + ); + let stored_pool = persister + .stores + .lock() + .unwrap() + .iter() + .any(|(_, changeset)| !changeset.account_address_pools.is_empty()); + assert!( + !stored_pool, + "no pool rows are written back for a removed wallet" + ); + } + + /// The single-shot send holds the generation's lifecycle gate through the + /// broadcast, as the prepared send does. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_hold_the_lifecycle_gate_through_a_send_payment_broadcast() { + let (manager, _persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xEA, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let teardown_blocked = Arc::new(Mutex::new(None)); + let iw_send = with_identity_broadcaster( + iw, + TeardownProbeBroadcaster { + generation: Arc::clone(wallet_arc.core().generation()), + teardown_blocked: Arc::clone(&teardown_blocked), + }, + ); + + iw_send + .dashpay() + .send_payment(&owner_id, &contact_id, 50_000, None, &signer, &provider) + .await + .expect("send"); + assert_eq!( + *teardown_blocked.lock().unwrap(), + Some(true), + "a teardown could start while the payment was being broadcast" + ); + } + + /// An address found paid at prepare that cannot be stored fails the + /// prepare with the store's error, and nothing is signed or reserved. + #[tokio::test] + async fn should_fail_the_prepare_when_an_address_found_paid_cannot_be_stored() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xE5, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let paid = next_send_address(iw, &accepting, owner_id, contact_id).await; + plant_resident_contact_payment(iw, wallet_id, owner_id, contact_id, &paid).await; + + *persister.fail_stores.lock().unwrap() = true; + let err = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 50_000, + None, + &signer, + &provider, + ) + .await + .err() + .expect("the evidence store fails the prepare"); + assert!( + matches!(err, PlatformWalletError::PersisterStore(_)), + "got {err:?}" + ); + *persister.fail_stores.lock().unwrap() = false; + // Nothing was reserved: the whole balance can still be prepared. + let full = iw + .dashpay() + .prepare_payment( + &accepting, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("the inputs were never reserved"); + iw.dashpay().abandon_prepared_payment(full).await; + } + + /// Once the broadcast succeeded, a payment record that cannot be stored + /// is logged, not returned: an error would read as "not sent" and invite + /// a second payment. + #[tokio::test] + async fn should_return_the_txid_when_send_payment_cannot_store_its_record() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xE6, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + + *persister.fail_payment_records.lock().unwrap() = true; + let (_txid, entry, _fee) = with_accepting_broadcaster(iw) + .dashpay() + .send_payment(&owner_id, &contact_id, 50_000, None, &signer, &provider) + .await + .expect("a broadcast payment is reported sent"); + assert_eq!(entry.amount_duffs, 50_000); + } + + /// An address found paid is stored used even when the send then fails + /// before it stores its own reservation — here on insufficient funds — + /// so a relaunch after the record is evicted does not hand it out again. + #[tokio::test] + async fn should_store_an_address_found_paid_even_when_the_send_build_fails() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xE1, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let paid = next_send_address(iw, &accepting, owner_id, contact_id).await; + plant_resident_contact_payment(iw, wallet_id, owner_id, contact_id, &paid).await; + persister.stores.lock().unwrap().clear(); + + let iw_send = with_accepting_broadcaster(iw); + iw_send + .dashpay() + .send_payment(&owner_id, &contact_id, 10_000_000, None, &signer, &provider) + .await + .expect_err("more than the wallet holds"); + let stored_used = persister + .stores + .lock() + .unwrap() + .iter() + .flat_map(|(_, changeset)| changeset.account_address_pools.iter()) + .any(|entry| { + entry + .addresses + .iter() + .any(|info| info.script_pubkey == paid.script_pubkey() && info.is_used()) + }); + assert!(stored_used, "the address found paid reaches disk"); + } + + /// An address found paid that cannot be stored fails the send with the + /// store's error, before anything is reserved, even when the send would + /// have failed anyway on the build. + #[tokio::test] + async fn should_report_a_failed_store_of_an_address_found_paid_in_send_payment() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + fund_bip44_account_0(&manager, wallet_id, 0xE3, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let accepting = core_with(wallet_arc.core(), AcceptingBroadcaster); + let paid = next_send_address(iw, &accepting, owner_id, contact_id).await; + plant_resident_contact_payment(iw, wallet_id, owner_id, contact_id, &paid).await; + + *persister.fail_stores.lock().unwrap() = true; + let err = with_accepting_broadcaster(iw) + .dashpay() + .send_payment(&owner_id, &contact_id, 10_000_000, None, &signer, &provider) + .await + .expect_err("more than the wallet holds"); + *persister.fail_stores.lock().unwrap() = false; + assert!( + matches!(err, PlatformWalletError::PersisterStore(_)), + "got {err:?}" + ); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0], + "the mark stays in memory" + ); + } + + /// A prepared send refused because a record pays its address reports a + /// failure to store that evidence, not the plain conflict — and still + /// gives back its inputs and keeps the mark in memory. + #[tokio::test] + async fn should_report_a_failed_store_when_a_prepared_payment_is_refused_on_a_paid_address() { + let (manager, persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + // One coin, so the retry proves the inputs were released. + fund_bip44_account_0(&manager, wallet_id, 0xE2, 120_000).await; + let (signer, provider) = test_signer_and_provider(); + let core = core_with(wallet_arc.core(), AcceptingBroadcaster); + + let prepared = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("prepare"); + let paid = dashcore::Address::from_script( + &payment_script(prepared.transaction(), 100_000), + Network::Testnet, + ) + .expect("a P2PKH payment output"); + plant_resident_contact_payment(iw, wallet_id, owner_id, contact_id, &paid).await; + *persister.fail_stores.lock().unwrap() = true; + let err = iw + .dashpay() + .send_prepared_payment(prepared) + .await + .expect_err("a paid address must not be paid again"); + *persister.fail_stores.lock().unwrap() = false; + assert!( + matches!(err, PlatformWalletError::PersisterStore(_)), + "got {err:?}" + ); + assert_eq!( + external_used_indices(&manager, wallet_id, owner_id, contact_id).await, + vec![0], + "the mark stays in memory" + ); + let retry = iw + .dashpay() + .prepare_payment( + &core, + &owner_id, + &contact_id, + 100_000, + None, + &signer, + &provider, + ) + .await + .expect("the refused send released the only coin"); + iw.dashpay().abandon_prepared_payment(retry).await; + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/prepared_payment.rs b/packages/rs-platform-wallet/src/wallet/identity/network/prepared_payment.rs new file mode 100644 index 00000000000..83d0af0f00b --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/prepared_payment.rs @@ -0,0 +1,841 @@ +//! Two-step DashPay contact payment: sign and reserve now, broadcast or +//! abandon after the user has seen the exact fee. +//! +//! [`DashPayView::send_payment`] builds, signs and broadcasts in one call and +//! reports the fee only afterwards, so a host has nothing to show on a +//! confirmation screen but the amount. The split here mirrors the L1 path a +//! plain send already takes ([`CoreWallet::finalize_transaction`] → +//! [`CoreWallet::broadcast_finalized_transaction`] / +//! [`CoreWallet::abandon_transaction`]): [`DashPayView::prepare_payment`] +//! picks the contact's next DIP-15 address, funds, reserves and signs, and +//! returns a [`PreparedDashPayPayment`] that carries the exact fee; +//! [`DashPayView::send_prepared_payment`] takes the address, broadcasts and +//! records the payment; [`DashPayView::abandon_prepared_payment`] releases +//! the inputs. +//! +//! Between the two steps the inputs are held by the finalized transaction's +//! owner-guarded reservation, under the same age bound the L1 path enforces — +//! a payment held past it is refused at send time with +//! [`PlatformWalletError::StaleReservation`] and must be prepared again. +//! +//! The contact address is NOT claimed while the payment waits. Prepare reads +//! the contact's next available DIP-15 address; the only pool write it makes +//! is marking used (and storing) addresses a resident record already pays, +//! which is evidence, not a claim. Nothing has to be undone when the payment +//! is abandoned, when its build fails, or when the future preparing it is +//! dropped. +//! +//! Send claims the address in two steps that keep the claim apart from the +//! evidence that the address was paid: +//! +//! 1. Under the manager lock it checks that the address is still available — +//! not used, not reserved, and paid by no resident transaction record — +//! and marks it **reserved**. The pool is stored, and flushed when the +//! backend buffers, before anything reaches the network. The FFI host +//! stores a reservation as used, so a relaunch after a crash mid-send does +//! not hand the address out again. SQLite stores it reversibly +//! (`reserved_at`) but does not yet restore a contact's external pool on +//! load, used rows included (dashpay/platform#5548). +//! 2. When the broadcast went out, or may have (an ambiguous outcome), the +//! reservation becomes **used** and is stored. When it provably did not — +//! the store failed, the network rejected it, or it was refused as stale +//! at dispatch — only the reservation is released, and that release is +//! stored. +//! +//! The used flag is never reverted. key-wallet sets it when it observes a +//! payment to the address, and a reservation it promotes that way is no +//! longer a reservation to release: a payment another device on the same +//! seed made there while this one was in flight keeps the address used, even +//! after the ChainLock evicted its record. The release lowers only what the +//! reservation raised: SQLite clears `reserved_at`, and the FFI host, which +//! stores a reservation as used, stores the released address as unused. +//! +//! Every address change is bound to the wallet generation the payment was +//! funded from: a payment that outlives its wallet (removed, or re-created +//! under the same id) never touches the new generation's pool. A send +//! dropped between reserve and settle leaves the address reserved, one +//! skipped index: the reservation sweep covers only Standard accounts. + +use dashcore::Address; +use dpp::prelude::Identifier; +use key_wallet::account::account_collection::DashpayAccountKey; +use key_wallet::account::AccountType; +use key_wallet::managed_account::address_pool::AddressState; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::ManagedCoreFundsAccount; +use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; +use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use std::sync::Arc; + +use super::contact_requests::ContactCryptoProvider; +use super::payments::{ + contact_address_paid_on_chain, contact_external_account, record_contact_addresses_paid_on_chain, +}; +use super::DashPayView; +use crate::broadcaster::TransactionBroadcaster; +use crate::changeset::{account_address_pool_entries, PlatformWalletChangeSet}; +use crate::error::PlatformWalletError; +use crate::util::now_secs; +use crate::wallet::core::{CoreWallet, SignedCoreTransaction, WalletGeneration}; +use crate::wallet::identity::types::dashpay::payment::PaymentEntry; +use crate::wallet::platform_wallet::PlatformWalletInfo; +use crate::wallet::reservations::reservation_expired; +use crate::SEND_FUNDING_SOURCES; + +/// The DashPay contact account index every contact payment uses, the same one +/// [`DashPayView::send_payment`] pins. +pub(super) const CONTACT_ACCOUNT_INDEX: u32 = 0; + +/// A signed DashPay contact payment that has not been broadcast yet. +/// +/// Its funding inputs stay reserved until it is handed to +/// [`DashPayView::send_prepared_payment`] or +/// [`DashPayView::abandon_prepared_payment`]. Deliberately not `Clone`: one +/// prepare yields one payment that can be sent or abandoned exactly once. +/// Dropping it without either leaves the reservation to key-wallet's TTL; +/// hosts abandon instead. +#[must_use = "a prepared payment holds its funding inputs until it is sent or abandoned"] +pub struct PreparedDashPayPayment { + core: CoreWallet, + signed: SignedCoreTransaction, + payment_address: Address, + from_identity_id: Identifier, + to_contact_id: Identifier, + amount_duffs: u64, + memo: Option, +} + +impl PreparedDashPayPayment { + /// The amount the contact receives, in duffs. + pub fn amount_duffs(&self) -> u64 { + self.amount_duffs + } + + /// The exact network fee of the signed transaction, in duffs — + /// Σ(selected inputs) − Σ(outputs), a dropped sub-dust change remainder + /// included. The broadcast cannot change it. + pub fn fee_duffs(&self) -> u64 { + self.signed.fee() + } + + /// What leaves the wallet: the amount plus the fee. + pub fn total_duffs(&self) -> u64 { + self.amount_duffs.saturating_add(self.signed.fee()) + } + + /// The signed transaction's id. + pub fn txid(&self) -> dashcore::Txid { + self.signed.transaction().txid() + } + + /// The signed transaction itself. + pub fn transaction(&self) -> &dashcore::Transaction { + self.signed.transaction() + } + + pub fn from_identity_id(&self) -> &Identifier { + &self.from_identity_id + } + + pub fn to_contact_id(&self) -> &Identifier { + &self.to_contact_id + } + + /// The core wallet the payment was funded from. A host compares its + /// generation with its own handle's; it must not take that generation's + /// lifecycle gate around [`DashPayView::send_prepared_payment`], which + /// takes it itself. + pub fn core_wallet(&self) -> &CoreWallet { + &self.core + } +} + +#[cfg(any(test, feature = "test-utils"))] +impl PreparedDashPayPayment { + /// A prepared payment around an already signed transaction, for tests + /// that need a payment to hand over without funding and signing one. + pub fn new_for_test( + core: CoreWallet, + signed: SignedCoreTransaction, + payment_address: Address, + from_identity_id: Identifier, + to_contact_id: Identifier, + amount_duffs: u64, + ) -> Self { + Self { + core, + signed, + payment_address, + from_identity_id, + to_contact_id, + amount_duffs, + memo: None, + } + } +} + +impl DashPayView<'_, B> { + /// Sign a Core payment to a DashPay contact without broadcasting it. + /// + /// Runs the first half of [`Self::send_payment`]: drains any deferred + /// contact-crypto build behind the seed-binding gate, reads the contact's + /// next available DIP-15 address, and funds, reserves and signs the + /// transaction from the same pooled sources a plain send uses + /// ([`SEND_FUNDING_SOURCES`]: BIP44 first, so change keeps returning to + /// BIP44). `core` must be this wallet's core handle; it funds the payment + /// and later broadcasts it. + /// + /// Addresses a resident record already pays are marked used and stored + /// first, as [`Self::send_payment`] does. Otherwise the pool is left as it + /// was: [`Self::send_prepared_payment`] reserves the address, stores that reservation before the broadcast, + /// then makes it used or releases it (see the module docs). Two payments + /// prepared to one contact at the same time therefore pay the same + /// address, and the second one sent is refused. + #[allow(clippy::too_many_arguments)] + pub async fn prepare_payment( + &self, + core: &CoreWallet, + from_identity_id: &Identifier, + to_contact_id: &Identifier, + amount_duffs: u64, + memo: Option, + signer: &S, + provider: &C, + ) -> Result, PlatformWalletError> + where + Bc: TransactionBroadcaster + ?Sized, + S: key_wallet::signer::Signer + Sync, + C: ContactCryptoProvider + Sync, + { + self.ensure_same_wallet(core)?; + + // Before the address lookup: the signerless sweep only enqueues the + // external-account build, so the first payment after a contact is + // established needs this drain to find the account at all. Runs + // before any lock is taken — the drain takes the manager lock itself. + self.drain_pending_contact_crypto_verified(provider, None) + .await?; + + // Under the contact-payment gate, like `send_payment`'s walk: the + // snapshot of addresses found paid is stored in the order it was + // taken. A failed store fails the prepare, as it fails a send. The + // lifecycle gate comes first, in the same order as + // `send_prepared_payment`: a wallet removed meanwhile gets no pool + // rows written back after its teardown. + let payment_address = { + let _lifecycle = core.generation_payment_guard().await; + if !core.is_current_generation().await { + return Err(PlatformWalletError::WalletRemovedBeforeSend(hex::encode( + self.wallet_id, + ))); + } + let _contact_gate = self.persister.lock_contact_payments().await; + let (payment_address, found_paid) = self + .next_contact_payment_address(core.generation(), from_identity_id, to_contact_id) + .await?; + if let Some(changeset) = found_paid { + self.store_durably(changeset)?; + } + payment_address + }; + + let builder = TransactionBuilder::new() + .set_selection_strategy(SelectionStrategy::LargestFirst) + .add_output(&payment_address, amount_duffs); + let signed = core + .finalize_transaction( + builder, + &SEND_FUNDING_SOURCES, + CONTACT_ACCOUNT_INDEX, + signer, + ) + .await?; + + Ok(PreparedDashPayPayment { + core: core.clone(), + signed, + payment_address, + from_identity_id: *from_identity_id, + to_contact_id: *to_contact_id, + amount_duffs, + memo, + }) + } + + /// Broadcast a prepared contact payment and record it on the sender. + /// + /// The second half of [`Self::send_payment`]: under the manager lock the + /// payment's address is checked to be still available and its funding + /// reservation still fresh, then reserved; the reservation is stored and + /// flushed before anything reaches the network. An address another + /// payment took meanwhile, a stale reservation, or a failed store sends + /// nothing and releases the inputs. A definitive rejection releases the + /// inputs too; an ambiguous outcome keeps them fenced, since the + /// transaction may have propagated. The address ends used when the + /// transaction went out or may have, and available again when it + /// provably did not (see the module docs). + /// + /// Returns the txid, the payment's [`PaymentEntry`] and the fee — the same + /// fee [`PreparedDashPayPayment::fee_duffs`] showed. Once the broadcast + /// succeeded the call succeeds even if the entry could not be recorded on + /// the sender (that failure is logged): the entry is then not in the + /// wallet's payment map until the reconstruction from transaction history + /// rebuilds it, without the memo. + /// + /// Takes the funding generation's lifecycle gate itself: a caller must + /// not hold it, since a teardown queued between the two reads would wait + /// on the outer one forever. A wallet removed or re-created since the + /// payment was prepared fails it with + /// [`PlatformWalletError::WalletRemovedBeforeSend`], nothing sent. + pub async fn send_prepared_payment( + &self, + prepared: PreparedDashPayPayment, + ) -> Result<(dashcore::Txid, PaymentEntry, u64), PlatformWalletError> + where + Bc: TransactionBroadcaster + ?Sized, + { + if let Err(e) = self.ensure_same_wallet(&prepared.core) { + // Not this wallet's payment: free its inputs and send nothing. + prepared.core.abandon_transaction(&prepared.signed).await; + return Err(e); + } + let PreparedDashPayPayment { + core, + signed, + payment_address, + from_identity_id, + to_contact_id, + amount_duffs, + memo, + } = prepared; + + // The generation's lifecycle gate, held across the liveness check and + // everything after it: a teardown cannot complete between the check + // and the broadcast, so a removed wallet's payment never reaches the + // network. Taken here rather than by each host wrapper, so a native + // caller gets the same exclusion. + let _lifecycle = core.generation_payment_guard().await; + if !core.is_current_generation().await { + core.abandon_transaction(&signed).await; + return Err(PlatformWalletError::WalletRemovedBeforeSend(hex::encode( + self.wallet_id, + ))); + } + + // Every contact-pool snapshot below is taken and stored under the + // contact-payment gate, which `send_payment` and + // `reserve_payment_address` take as well: two sends to one contact + // store their snapshots in the order they took them, so an older + // snapshot never overwrites a newer reservation. The gate is not held + // across the broadcast. + let contact_gate = self.persister.lock_contact_payments().await; + + // Reserve under the write guard; store after it drops. The contact + // gate, not the manager lock, orders pool snapshots, so the store + // need not hold the lock (the payment record below is different). + let generation = core.generation(); + let reserved = match self + .reserve_contact_payment_address( + generation, + &signed, + &from_identity_id, + &to_contact_id, + &payment_address, + ) + .await + { + Ok(changeset) => changeset, + Err(refusal) => { + // A failed store outranks the refusal: the caller learns the + // store is failing, not just to try again. The mark stays in + // memory and rides the contact's next pool snapshot. + let stored = refusal + .found_paid + .map_or(Ok(()), |found_paid| self.store_durably(*found_paid)); + core.abandon_transaction(&signed).await; + return Err(match stored { + Ok(()) => refusal.error, + Err(e) => { + tracing::warn!( + refusal = %refusal.error, + "a contact payment was refused and the address found paid could \ + not be stored" + ); + e + } + }); + } + }; + if let Err(e) = self.store_durably(reserved) { + // Nothing is on the wire: release the address while the gate is + // still held and before the inputs, so no other send finds it + // still reserved and pays the next index. + self.settle_payment_address_gated( + generation, + &from_identity_id, + &to_contact_id, + &payment_address, + AddressOutcome::NotSent, + ) + .await; + drop(contact_gate); + core.abandon_transaction(&signed).await; + return Err(e); + } + drop(contact_gate); + + // The age guard, the in-broadcast pin and the owner-guarded release + // on a definitive rejection all live in the finalized-broadcast path. + let broadcast = core.broadcast_finalized_transaction(&signed).await; + // Rejected, or refused as stale at dispatch: provably not on the + // wire. Anything else went out or may have. On a rejection the + // finalized-broadcast path has already released the inputs, so a + // build that takes them before the release below sees this address + // still reserved and pays the next index; this one becomes available + // again, still inside the gap window. + let outcome = match &broadcast { + Err( + PlatformWalletError::TransactionBroadcast(_) + | PlatformWalletError::StaleReservation, + ) => AddressOutcome::NotSent, + _ => AddressOutcome::Sent, + }; + self.settle_payment_address( + generation, + &from_identity_id, + &to_contact_id, + &payment_address, + outcome, + ) + .await; + let txid = broadcast?; + + tracing::info!( + from_identity = %from_identity_id, + to_contact = %to_contact_id, + amount_duffs, + %txid, + payment_address = %payment_address, + "DashPay payment broadcast" + ); + + // The payment is on the wire from here on, so nothing below may fail + // the call: an error would read as "not sent" and invite the user to + // pay again. A record that cannot be written is logged instead; the + // sent-payment reconstruction from the wallet's transaction history + // (`reconcile_sent_payments_from_tx_history`) rebuilds it, without + // the memo. + let entry = PaymentEntry::new_sent(to_contact_id, amount_duffs, memo); + { + // Stored under the write guard, like every payment-record write: + // the record's changeset carries the identity's whole payment map, + // and a confirmation recorded right after the guard dropped would + // otherwise be overwritten on disk by this older snapshot. + let mut wm = self.wallet_manager.write().await; + // Bound to the funding generation like every other write here: a + // wallet re-created meanwhile must not receive this payment. + let recorded = match self + .generation_info_mut(&mut wm, core.generation()) + .ok() + .and_then(|info| { + info.identity_manager + .managed_identity_mut(&from_identity_id) + }) { + Some(managed) => managed + .record_dashpay_payment(txid.to_string(), entry.clone(), &self.persister) + .map_err(|e| e.to_string()), + None => Err( + "sender identity not found, or the wallet was removed or re-created" + .to_string(), + ), + }; + if let Err(e) = recorded { + tracing::error!( + %txid, + error = %e, + "DashPay payment broadcast but not recorded locally" + ); + } + } + + Ok((txid, entry, signed.fee())) + } + + /// Drop a prepared contact payment: release its inputs (owner-guarded, so + /// inputs a TTL sweep has since handed to another build stay with it). + /// The payment's address was never claimed, so there is nothing else to + /// undo and nothing is persisted. + pub async fn abandon_prepared_payment(&self, prepared: PreparedDashPayPayment) + where + Bc: TransactionBroadcaster + ?Sized, + { + prepared.core.abandon_transaction(&prepared.signed).await; + } + + fn ensure_same_wallet(&self, core: &CoreWallet) -> Result<(), PlatformWalletError> + where + Bc: TransactionBroadcaster + ?Sized, + { + if core.wallet_id == self.wallet_id + && Arc::ptr_eq(&core.wallet_manager, &self.wallet_manager) + { + Ok(()) + } else { + Err(PlatformWalletError::InvalidParameter( + "the core wallet does not belong to this identity wallet".to_string(), + )) + } + } + + /// The contact's next available payment address, after marking used + /// every next address a resident record already pays. Returns the pool + /// snapshot recording those marks, if any, for the caller to store once + /// the guard drops. Reserves nothing; the only other change is deriving + /// lookahead addresses into the in-memory pool, which `send_payment`'s + /// selection does as well. + async fn next_contact_payment_address( + &self, + generation: &Arc, + from_identity_id: &Identifier, + to_contact_id: &Identifier, + ) -> Result<(Address, Option), PlatformWalletError> { + let mut wm = self.wallet_manager.write().await; + let found_paid = record_contact_addresses_paid_on_chain( + &mut wm, + self.wallet_id, + Some(generation), + from_identity_id, + to_contact_id, + )?; + let (contact_xpub, external_account) = contact_external_account( + &mut wm, + self.wallet_id, + Some(generation), + from_identity_id, + to_contact_id, + )?; + let payment_address = external_account + .next_address(Some(&contact_xpub), true) + .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; + Ok((payment_address, found_paid)) + } + + /// Check that the payment can still go out — its address still available + /// and its funding reservation still fresh — then reserve the address and + /// snapshot the pool for the pre-broadcast store. On a refusal nothing is + /// changed, except an available address a resident record pays, which is + /// marked used. + async fn reserve_contact_payment_address( + &self, + generation: &Arc, + signed: &SignedCoreTransaction, + from_identity_id: &Identifier, + to_contact_id: &Identifier, + payment_address: &Address, + ) -> Result { + let mut wm = self.wallet_manager.write().await; + let info = self.generation_info_mut(&mut wm, generation)?; + // Checked here as well as at dispatch: a payment held past the bound + // is the common way a confirmation screen goes stale, and refusing it + // before the mark keeps its address available. The dispatch check stays + // the authoritative one. + if reservation_expired( + signed.reservation_height(), + Some(info.core_wallet.last_processed_height()), + ) { + return Err(PlatformWalletError::StaleReservation.into()); + } + let external_account = info + .core_wallet + .accounts + .dashpay_external_accounts + .get_mut(&contact_key(from_identity_id, to_contact_id)) + .ok_or_else(|| { + // Not the "account not built yet" wording the prepare path + // uses: this account existed at prepare and is gone now. + PlatformWalletError::InvalidIdentityData(format!( + "the payment account for contact {to_contact_id} was removed while the \ + payment waited; nothing was sent" + )) + })?; + let state = external_account + .managed_account_type() + .address_pools() + .iter() + .find_map(|pool| pool.address_info(payment_address)) + .map(|info| info.is_available()); + match state { + None => { + return Err(PlatformWalletError::PreparedPaymentAddressTaken( + "the payment's address is no longer in the contact's address pool".to_string(), + ) + .into()) + } + // Available, and no resident record pays it. Otherwise another + // payment to the contact went to this address while this one + // waited — sent from here, or seen on-chain from another device + // on the same seed — or another hand-out reserved it. A second + // payment there would link the two on-chain. + Some(true) + if !contact_address_paid_on_chain( + external_account, + &payment_address.script_pubkey(), + ) => {} + Some(available) => { + // Paid on-chain but not yet marked: record that, persisted, + // or the next prepare would pick this address again and be + // refused the same way. + let found_paid = (available && external_account.mark_address_used(payment_address)) + .then(|| { + Box::new(pool_changeset( + from_identity_id, + to_contact_id, + external_account, + )) + }); + return Err(ReserveRefusal { + error: PlatformWalletError::PreparedPaymentAddressTaken( + "another payment to this contact took the same address while this one \ + waited" + .to_string(), + ), + found_paid, + }); + } + } + reserve_address(external_account, payment_address); + Ok(pool_changeset( + from_identity_id, + to_contact_id, + external_account, + )) + } + + /// Settle the reservation a send took: promote it to used when the + /// transaction went out or may have, release it when it provably did not. + /// The result is stored after the guard drops; a failed store is logged — + /// the payment's outcome no longer depends on it. + /// + /// Only the reservation is touched. An address key-wallet has meanwhile + /// seen paid is already used and stays so — the reservation it promoted + /// is gone, and the release finds nothing to release. + pub(super) async fn settle_payment_address( + &self, + generation: &Arc, + from_identity_id: &Identifier, + to_contact_id: &Identifier, + payment_address: &Address, + outcome: AddressOutcome, + ) { + // Held from the snapshot through its store, like the reservation. + let _contact_gate = self.persister.lock_contact_payments().await; + self.settle_payment_address_gated( + generation, + from_identity_id, + to_contact_id, + payment_address, + outcome, + ) + .await; + } + + /// [`Self::settle_payment_address`] for a caller already holding the + /// contact-payment gate. + pub(super) async fn settle_payment_address_gated( + &self, + generation: &Arc, + from_identity_id: &Identifier, + to_contact_id: &Identifier, + payment_address: &Address, + outcome: AddressOutcome, + ) { + let settled = { + let mut wm = self.wallet_manager.write().await; + let Ok(info) = self.generation_info_mut(&mut wm, generation) else { + return; + }; + let Some(external_account) = info + .core_wallet + .accounts + .dashpay_external_accounts + .get_mut(&contact_key(from_identity_id, to_contact_id)) + else { + tracing::warn!( + to_contact = %to_contact_id, + ?outcome, + "external account not found while settling a contact payment's address" + ); + return; + }; + // A resident record paying the address is evidence too, even if + // whatever made it resident did not mark the address. + let paid = + contact_address_paid_on_chain(external_account, &payment_address.script_pubkey()); + let changed = match outcome { + AddressOutcome::NotSent if !paid => { + release_address_reservation(external_account, payment_address) + } + _ => external_account.mark_address_used(payment_address), + }; + if !changed { + let in_pool = external_account + .managed_account_type() + .address_pools() + .iter() + .any(|pool| pool.address_info(payment_address).is_some()); + if !in_pool { + tracing::warn!( + to_contact = %to_contact_id, + payment_address = %payment_address, + ?outcome, + "a contact payment's address left the pool while it was sent; \ + it could not be settled" + ); + return; + } + // Nothing to change in memory — typically already used: the + // wallet observed the payment, this one or another device's, + // while it was in flight, and that observation stores no + // contact pool. The pool is stored all the same, so disk + // matches memory rather than the reservation. + } + pool_changeset(from_identity_id, to_contact_id, external_account) + }; + // A release is flushed like the reservation, so a relaunch does not + // read back the reservation it replaces. A used address needs no + // flush: the reservation already stored guards it as the used mark + // would — the FFI host reloads either as used. + let stored = match outcome { + AddressOutcome::NotSent => self.store_durably(settled), + AddressOutcome::Sent => self + .persister + .store(settled) + .map_err(|e| self.persister.classify_store_failure(e)), + }; + if let Err(e) = stored { + tracing::warn!( + error = %e, + ?outcome, + "failed to persist a contact payment's settled address" + ); + } + } + + /// Store `changeset` and, on a backend that buffers, flush it, so it is + /// on disk when this returns. A failed flush is `PersisterFlush` with the + /// backend's own kind: the round may still be buffered and commit at a + /// later flush, so it is not proof that nothing was stored. + pub(super) fn store_durably( + &self, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PlatformWalletError> { + self.persister + .store(changeset) + .map_err(|e| self.persister.classify_store_failure(e))?; + if !self.persister.store_commits_inline() { + self.persister + .flush() + .map_err(PlatformWalletError::from_flush_failure)?; + } + Ok(()) + } + + /// The wallet info of `generation`, or `WalletNotFound` when the wallet + /// under this id is gone or is a newer generation. + fn generation_info_mut<'w>( + &self, + wm: &'w mut key_wallet_manager::WalletManager, + generation: &Arc, + ) -> Result<&'w mut PlatformWalletInfo, PlatformWalletError> { + wm.get_wallet_info_mut(&self.wallet_id) + .filter(|info| Arc::ptr_eq(&info.generation, generation)) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id))) + } +} + +/// Whether a send's transaction went out (or may have), or provably did not. +#[derive(Debug, Clone, Copy)] +pub(super) enum AddressOutcome { + Sent, + NotSent, +} + +/// Why [`DashPayView::reserve_contact_payment_address`] refused a send, and — +/// when it found the address paid on-chain but not yet marked — the pool +/// snapshot recording that, to store after the guard drops. +struct ReserveRefusal { + error: PlatformWalletError, + found_paid: Option>, +} + +impl From for ReserveRefusal { + fn from(error: PlatformWalletError) -> Self { + Self { + error, + found_paid: None, + } + } +} + +/// Mark `address` reserved in whichever of `account`'s pools holds it, if it +/// is available there — a used address is never downgraded to a reservation +/// a release could then free. Returns whether it was reserved. +pub(super) fn reserve_address(account: &mut ManagedCoreFundsAccount, address: &Address) -> bool { + let reserved_at = now_secs(); + let mut reserved = false; + for pool in account.managed_account_type_mut().address_pools_mut() { + if let Some(info) = pool.address_info_mut(address) { + if info.is_available() { + info.state = AddressState::Reserved { at: reserved_at }; + reserved = true; + } + } + } + reserved +} + +/// Release `address`'s reservation. A used or available address is left as +/// it is. Returns whether a reservation was released. +pub(super) fn release_address_reservation( + account: &mut ManagedCoreFundsAccount, + address: &Address, +) -> bool { + account + .managed_account_type_mut() + .address_pools_mut() + .into_iter() + .any(|pool| { + pool.address_index(address) + .is_some_and(|index| pool.release_reservation(index)) + }) +} + +pub(super) fn contact_key( + from_identity_id: &Identifier, + to_contact_id: &Identifier, +) -> DashpayAccountKey { + DashpayAccountKey { + index: CONTACT_ACCOUNT_INDEX, + user_identity_id: from_identity_id.to_buffer(), + friend_identity_id: to_contact_id.to_buffer(), + } +} + +pub(super) fn pool_changeset( + from_identity_id: &Identifier, + to_contact_id: &Identifier, + external_account: &ManagedCoreFundsAccount, +) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + account_address_pools: account_address_pool_entries( + AccountType::DashpayExternalAccount { + index: CONTACT_ACCOUNT_INDEX, + user_identity_id: from_identity_id.to_buffer(), + friend_identity_id: to_contact_id.to_buffer(), + }, + external_account.managed_account_type().address_pools(), + ), + ..Default::default() + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 0e380b080e6..327364a9e0a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -157,8 +157,9 @@ impl ManagedIdentity { // dropping a Sent entry + memo that has no on-chain recovery (the // self-healing sweep callers re-derive Received from UTXOs next pass). // A failed overwrite restores the previous entry rather than deleting - // it. Either way the persist result is returned, not swallowed: the - // user-initiated send path (`send_payment`) surfaces it in the UI. + // it. Either way the persist result is returned, not swallowed. The + // send paths log it instead of failing a payment already on the wire; + // the sent-payment reconstruction rebuilds the entry, without the memo. let previous = self.dashpay.payments.insert(tx_id.clone(), entry.clone()); let mut cs: crate::changeset::PlatformWalletChangeSet = self.snapshot_changeset().into(); // Ride the changed row on `dashpay_payments_overlay` as well: the diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 548e2d19c83..52e10a92bc7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2998,6 +2998,31 @@ extension ManagedPlatformWallet { }.value } + /// Runs `call` with the two 32-byte identity pointers and the memo as a C + /// string (nil without one), keeping `coreSigner` alive across it. + /// + /// `withExtendedLifetime` (not a bare `_ = coreSigner`) keeps the resolver + /// alive across the synchronous FFI call — the optimizer can otherwise drop + /// it mid-call and the vtable callback would use-after-free. + private static func withContactPaymentArguments( + from fromBytes: [UInt8], + to toBytes: [UInt8], + memo: String?, + coreSigner: MnemonicResolver, + _ call: (UnsafePointer, UnsafePointer, UnsafePointer?) -> PlatformWalletFFIResult + ) -> PlatformWalletFFIResult { + withExtendedLifetime(coreSigner) { + fromBytes.withUnsafeBufferPointer { fromBp in + toBytes.withUnsafeBufferPointer { toBp in + if let memo { + return memo.withCString { call(fromBp.baseAddress!, toBp.baseAddress!, $0) } + } + return call(fromBp.baseAddress!, toBp.baseAddress!, nil) + } + } + } + } + /// 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 @@ -3075,38 +3100,132 @@ extension ManagedPlatformWallet { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ) - // `withExtendedLifetime` (not a bare `_ = coreSigner`) keeps the - // resolver alive across the synchronous FFI call — the optimizer - // can otherwise drop it mid-call and the vtable callback would - // use-after-free. - let result: PlatformWalletFFIResult = withExtendedLifetime(coreSigner) { - fromBytes.withUnsafeBufferPointer { fromBp -> PlatformWalletFFIResult in - toBytes.withUnsafeBufferPointer { toBp -> PlatformWalletFFIResult in - let call: (UnsafePointer?) -> PlatformWalletFFIResult = { memoPtr in - platform_wallet_send_dashpay_payment( - handle, - fromBp.baseAddress!, - toBp.baseAddress!, - amountDuffs, - memoPtr, - coreSigner.handle, - &txidTuple, - &feeDuffs - ) - } - if let memoCopy { - return memoCopy.withCString { call($0) } - } else { - return call(nil) - } - } - } + let result = Self.withContactPaymentArguments( + from: fromBytes, to: toBytes, memo: memoCopy, coreSigner: coreSigner + ) { fromPtr, toPtr, memoPtr in + platform_wallet_send_dashpay_payment( + handle, + fromPtr, + toPtr, + amountDuffs, + memoPtr, + coreSigner.handle, + &txidTuple, + &feeDuffs + ) } try result.check() let txid = Swift.withUnsafeBytes(of: &txidTuple) { Data($0) } return (txid: txid, feeDuffs: feeDuffs) }.value } + + /// Sign a Dash payment to an established DashPay contact without + /// broadcasting it, so the exact network fee and total can be shown + /// before the user confirms. + /// + /// Pass the result to exactly one of `sendPreparedDashPayPayment(_:)` or + /// `abandonPreparedDashPayPayment(_:)`. Until then its inputs stay + /// reserved. A payment held while the wallet processes many blocks is + /// refused at send with `.staleReservationToken` (nothing sent, + /// everything released): prepare it again. + public func prepareDashPayPayment( + fromIdentityId: Identifier, + toContactIdentityId: Identifier, + amountDuffs: UInt64, + memo: String? = nil + ) async throws -> PreparedDashPayPayment { + let handle = self.handle + let fromBytes: [UInt8] = fromIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let toBytes: [UInt8] = toContactIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let memoCopy = memo + // Same signer lifetime contract as `sendDashPayPayment`. + let coreSigner = MnemonicResolver() + return try await Task.detached(priority: .userInitiated) { [self] () -> PreparedDashPayPayment in + try withExtendedLifetime(self) { + var paymentHandle: Handle = 0 + var feeDuffs: UInt64 = 0 + var totalDuffs: UInt64 = 0 + var txidTuple: FFIByteTuple32 = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + let result = Self.withContactPaymentArguments( + from: fromBytes, to: toBytes, memo: memoCopy, coreSigner: coreSigner + ) { fromPtr, toPtr, memoPtr in + platform_wallet_prepare_dashpay_payment( + handle, + fromPtr, + toPtr, + amountDuffs, + memoPtr, + coreSigner.handle, + &paymentHandle, + &feeDuffs, + &totalDuffs, + &txidTuple + ) + } + try result.check() + return PreparedDashPayPayment( + handle: paymentHandle, + amountDuffs: amountDuffs, + feeDuffs: feeDuffs, + totalDuffs: totalDuffs, + txid: hashData(txidTuple) + ) + } + }.value + } + + /// Broadcast a prepared contact payment and record it. Consumes + /// `payment` whatever the outcome. Returns the 32-byte txid and the + /// fee, which is the one the prepared payment showed. + /// + /// Throws `.staleReservationToken`, nothing sent, when another + /// payment to the same contact took this payment's address while it + /// waited: prepare it again. Throws + /// `.transactionBroadcastUnconfirmed` when the transaction may have + /// reached the network: `payment.txid` names it, to watch for instead of + /// paying again. + @discardableResult + public func sendPreparedDashPayPayment( + _ payment: PreparedDashPayPayment + ) async throws -> (txid: Data, feeDuffs: UInt64) { + let handle = self.handle + let paymentHandle = try payment.take() + return try await Task.detached(priority: .userInitiated) { [self] () -> (txid: Data, feeDuffs: UInt64) in + try withExtendedLifetime(self) { + var feeDuffs: UInt64 = 0 + var txidTuple: FFIByteTuple32 = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + try platform_wallet_send_prepared_dashpay_payment( + handle, + paymentHandle, + &txidTuple, + &feeDuffs + ).check() + let txid = Swift.withUnsafeBytes(of: &txidTuple) { Data($0) } + return (txid: txid, feeDuffs: feeDuffs) + } + }.value + } + + /// Drop a prepared contact payment without sending it: its inputs are + /// released. A payment the wallet's removal or re-creation already + /// dropped has nothing left to release, and abandoning it succeeds. + public func abandonPreparedDashPayPayment(_ payment: PreparedDashPayPayment) async throws { + let paymentHandle = try payment.take() + await Task.detached(priority: .userInitiated) { + platform_wallet_prepared_dashpay_payment_free(paymentHandle) + }.value + } } // MARK: - DashPay Profile operations diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PreparedDashPayPayment.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PreparedDashPayPayment.swift new file mode 100644 index 00000000000..98a2f5849ae --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PreparedDashPayPayment.swift @@ -0,0 +1,56 @@ +import Foundation +import DashSDKFFI + +/// A DashPay contact payment signed in Rust and not broadcast yet. +/// +/// Its funding inputs stay reserved until it is passed to exactly one of +/// `ManagedPlatformWallet.sendPreparedDashPayPayment(_:)` or +/// `ManagedPlatformWallet.abandonPreparedDashPayPayment(_:)`. Releasing the +/// object without either abandons it. +/// +/// `@unchecked Sendable`: every stored property is immutable except the +/// native handle, which only `take()` changes, under `lock`. +public final class PreparedDashPayPayment: @unchecked Sendable { + private let lock = NSLock() + private var nativeHandle: Handle + /// The amount the contact receives, in duffs. + public let amountDuffs: UInt64 + /// The exact network fee of the signed transaction, in duffs. + public let feeDuffs: UInt64 + /// What leaves the wallet: amount plus fee, in duffs. + public let totalDuffs: UInt64 + /// The signed transaction's 32-byte id, in the byte order the send + /// returns. It identifies the payment when a send's outcome is unknown. + public let txid: Data + + init(handle: Handle, amountDuffs: UInt64, feeDuffs: UInt64, totalDuffs: UInt64, txid: Data) { + self.nativeHandle = handle + self.amountDuffs = amountDuffs + self.feeDuffs = feeDuffs + self.totalDuffs = totalDuffs + self.txid = txid + } + + deinit { + guard nativeHandle != 0 else { return } + // Abandoning takes the wallet-manager lock to release the inputs; + // the last reference is often dropped on the main thread, which must + // not wait on it. + let handle = nativeHandle + DispatchQueue.global(qos: .utility).async { + platform_wallet_prepared_dashpay_payment_free(handle) + } + } + + /// Hand the native handle over to a send or an abandon, once. + func take() throws -> Handle { + lock.lock() + defer { lock.unlock() } + guard nativeHandle != 0 else { + throw PlatformWalletError.unknown("PreparedDashPayPayment already sent or abandoned") + } + let value = nativeHandle + nativeHandle = 0 + return value + } +}