From 94cddfa9747430be61474eb02731f5754a348bb9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 7 Aug 2026 19:47:54 +0700 Subject: [PATCH 1/5] feat(platform-wallet): pool BIP44 + BIP32 + DashPay receiving funds on the send path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt rust-dashcore #925 (multi-account funding) and #929 (DashPay contact-account sources): a plain send now draws from every spendable transparent source — SEND_FUNDING_SOURCES = [BIP44, BIP32, AllDashpayReceivingFunds] — so coins a contact paid us are spendable without picking an account. Change returns to BIP44 (the first pooled source); CoinJoin stays out (separate privacy domain), as do a contact's watch-only external coins (excluded upstream by the receiving-side selector). Pin bump dca5b05b -> 944e53a5 (exactly #926 + #925 + #929). finalize_transaction takes a source list; a single-element list keeps the old strict one-account contract, a pooled list skips missing sources and errors only when nothing funds. SignedCoreTransaction and the deferred-payment registry record EVERY contributing AccountType, and release/abandon/rejected-broadcast reconcile the one build token against each account's ReservationSet. FFI gains the AllSpendable selector (single-account APIs reject it with a typed parameter error); JNI maps int 3; Kotlin and Swift builder enums gain the value and the send entry points (sendToAddresses, buildSignedPayment, finalizeAtomic) default to it. Tests: pooled finalize spans both standard families when neither covers the payment alone, tolerates a wallet with no DashPay accounts, records both contributors, and abandon releases every account (identical rebuild succeeds); single-source strictness pinned. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 24 +- Cargo.toml | 16 +- .../dashsdk/wallet/CoreTransactionBuilder.kt | 8 + .../dashsdk/wallet/ManagedPlatformWallet.kt | 17 +- .../src/core_wallet/addresses.rs | 8 +- .../src/core_wallet/broadcast.rs | 2 +- .../src/core_wallet/transaction_builder.rs | 54 ++- .../rs-platform-wallet-ffi/src/manager.rs | 10 +- packages/rs-platform-wallet-ffi/src/wallet.rs | 5 +- packages/rs-platform-wallet/src/lib.rs | 2 +- .../rs-platform-wallet/src/test_support.rs | 67 +++ .../src/wallet/core/broadcast.rs | 18 +- .../rs-platform-wallet/src/wallet/core/mod.rs | 2 +- .../src/wallet/core/transaction.rs | 454 ++++++++++++------ .../src/wallet/core/wallet.rs | 52 +- .../src/wallet/identity/network/payments.rs | 2 +- .../src/wallet/signed_payment_registry.rs | 46 +- .../rs-unified-sdk-jni/src/wallet_manager.rs | 1 + .../CoreWallet/CoreTransactionBuilder.swift | 11 +- 19 files changed, 534 insertions(+), 265 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a20399021bf..d448b3594d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" dependencies = [ "bincode", "dashcore-private", @@ -2904,7 +2904,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" [[package]] name = "glob" @@ -4095,7 +4095,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" dependencies = [ "aes", "async-trait", @@ -4124,7 +4124,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4140,7 +4140,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29#dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" +source = "git+https://github.com/dashpay/rust-dashcore?rev=944e53a503a88daa0d7706d7d7c5f5350213d295#944e53a503a88daa0d7706d7d7c5f5350213d295" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 41182275c4c..b030762325f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "dca5b05b83a9a2d6fd7b650e7cce4356d1c2ec29" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "944e53a503a88daa0d7706d7d7c5f5350213d295" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "944e53a503a88daa0d7706d7d7c5f5350213d295" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "944e53a503a88daa0d7706d7d7c5f5350213d295" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "944e53a503a88daa0d7706d7d7c5f5350213d295" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "944e53a503a88daa0d7706d7d7c5f5350213d295" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "944e53a503a88daa0d7706d7d7c5f5350213d295" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "944e53a503a88daa0d7706d7d7c5f5350213d295" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "944e53a503a88daa0d7706d7d7c5f5350213d295" } tokio-metrics = "0.5" diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt index 740649415b5..65971485ab6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt @@ -36,6 +36,14 @@ class CoreTransactionBuilder internal constructor(network: Network) : AutoClosea BIP44(0), BIP32(1), COIN_JOIN(2), + + /** + * Pool every spendable transparent source: BIP44 + BIP32 + all DashPay + * contact-receiving accounts. Change returns to BIP44 (the first + * pooled source). CoinJoin stays out (separate privacy domain), as do + * a contact's watch-only external coins. The default for a plain send. + */ + ALL_SPENDABLE(3), } /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index 270751a611d..dc2593dd30d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -105,10 +105,19 @@ class ManagedPlatformWallet internal constructor( ) } - /** Standard account derivation shape for [sendToAddresses]. */ + /** Funding-source selector for [sendToAddresses] / [buildSignedPayment]. */ enum class AccountType(val ffiValue: Int) { BIP44(0), BIP32(1), + + /** + * Pool every spendable transparent source — BIP44 + BIP32 + all + * DashPay contact-receiving accounts — with change returning to + * BIP44. CoinJoin is excluded (separate privacy domain), as are a + * contact's watch-only coins. The default for plain sends, so funds + * a contact paid us are spendable without picking an account. + */ + ALL_SPENDABLE(3), } /** @@ -142,7 +151,7 @@ class ManagedPlatformWallet internal constructor( recipients: List>, network: org.dashfoundation.dashsdk.Network, coreSignerHandle: Long, - accountType: AccountType = AccountType.BIP44, + accountType: AccountType = AccountType.ALL_SPENDABLE, accountIndex: Int = 0, ): String = gate.op { require(accountIndex >= 0) { "accountIndex must be non-negative, got $accountIndex" } @@ -153,6 +162,7 @@ class ManagedPlatformWallet internal constructor( val builderAccountType = when (accountType) { AccountType.BIP44 -> CoreTransactionBuilder.AccountType.BIP44 AccountType.BIP32 -> CoreTransactionBuilder.AccountType.BIP32 + AccountType.ALL_SPENDABLE -> CoreTransactionBuilder.AccountType.ALL_SPENDABLE } mapNativeErrors { val builder = CoreTransactionBuilder(network) @@ -341,7 +351,7 @@ class ManagedPlatformWallet internal constructor( recipients: List>, network: org.dashfoundation.dashsdk.Network, coreSignerHandle: Long, - accountType: AccountType = AccountType.BIP44, + accountType: AccountType = AccountType.ALL_SPENDABLE, accountIndex: Int = 0, opReturnData: ByteArray? = null, preserveOutputOrder: Boolean = false, @@ -365,6 +375,7 @@ class ManagedPlatformWallet internal constructor( val builderAccountType = when (accountType) { AccountType.BIP44 -> CoreTransactionBuilder.AccountType.BIP44 AccountType.BIP32 -> CoreTransactionBuilder.AccountType.BIP32 + AccountType.ALL_SPENDABLE -> CoreTransactionBuilder.AccountType.ALL_SPENDABLE } mapNativeErrors { // One atomic native operation: select + reserve + sign + register. diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/addresses.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/addresses.rs index 01d8953ad14..6b89056d60a 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/addresses.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/addresses.rs @@ -5,7 +5,6 @@ use crate::error::*; use crate::handle::*; use crate::runtime::runtime; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; -use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use std::ffi::CString; use std::os::raw::c_char; @@ -65,7 +64,12 @@ pub unsafe extern "C" fn core_wallet_set_gap_limit( account_index: u32, gap_limit: u32, ) -> PlatformWalletFFIResult { - let source: AccountTypePreference = account_type.into(); + let Some(source) = account_type.single_preference() else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "AllSpendable pools multiple accounts; set gap limits per account".to_string(), + ); + }; let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { runtime().block_on(wallet.set_gap_limit(source, account_index, gap_limit)) 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 2270d73367e..8eacdf4f355 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -269,7 +269,7 @@ mod tests { &Address::dummy(Network::Testnet, usize::from(tag)), 1_000_000, ), - AccountTypePreference::BIP44, + &[AccountTypePreference::BIP44], 0, signer, )) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index 35e78ee31a8..55a6d181c6c 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -61,14 +61,37 @@ pub enum CoreAccountTypeFFI { BIP44, BIP32, CoinJoin, + /// Pool every spendable transparent source: BIP44 + BIP32 + all DashPay + /// contact-receiving accounts (`platform_wallet::SEND_FUNDING_SOURCES`). + /// Change returns to BIP44 (the first pooled source). CoinJoin stays out + /// (separate privacy domain), as do a contact's watch-only external + /// coins. The default selector for a plain send. + AllSpendable, } -impl From for AccountTypePreference { - fn from(value: CoreAccountTypeFFI) -> Self { - match value { - CoreAccountTypeFFI::BIP44 => AccountTypePreference::BIP44, - CoreAccountTypeFFI::BIP32 => AccountTypePreference::BIP32, - CoreAccountTypeFFI::CoinJoin => AccountTypePreference::CoinJoin, +impl CoreAccountTypeFFI { + /// The funding sources this selector pools, in funding order — handed to + /// [`CoreWallet::finalize_transaction`]'s multi-source API. Single-family + /// selectors keep their strict one-account semantics. + /// The single account family this selector names, or `None` for the + /// pooled [`AllSpendable`](Self::AllSpendable) — used by APIs that address + /// exactly one account (gap limits, per-account UTXO listing), which must + /// reject the pooled selector with a typed parameter error. + pub(crate) fn single_preference(self) -> Option { + match self { + CoreAccountTypeFFI::BIP44 => Some(AccountTypePreference::BIP44), + CoreAccountTypeFFI::BIP32 => Some(AccountTypePreference::BIP32), + CoreAccountTypeFFI::CoinJoin => Some(AccountTypePreference::CoinJoin), + CoreAccountTypeFFI::AllSpendable => None, + } + } + + pub(crate) fn funding_sources(self) -> &'static [AccountTypePreference] { + match self { + CoreAccountTypeFFI::BIP44 => &[AccountTypePreference::BIP44], + CoreAccountTypeFFI::BIP32 => &[AccountTypePreference::BIP32], + CoreAccountTypeFFI::CoinJoin => &[AccountTypePreference::CoinJoin], + CoreAccountTypeFFI::AllSpendable => &platform_wallet::SEND_FUNDING_SOURCES, } } } @@ -120,7 +143,7 @@ pub unsafe extern "C" fn core_wallet_tx_builder_finalize( MnemonicResolverCoreSigner::new(core_signer_handle, wallet.wallet_id(), wallet.network()); let finalized = runtime().block_on(wallet.core().finalize_transaction( inner, - account_type.into(), + account_type.funding_sources(), account_index, &signer, )); @@ -255,7 +278,7 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( // Atomic select + reserve + sign in one wallet-manager critical section. let finalized = runtime().block_on(wallet.core().finalize_transaction( inner, - account_type.into(), + account_type.funding_sources(), account_index, &signer, )); @@ -391,11 +414,9 @@ fn managed_account( source: AccountTypePreference, account_index: u32, ) -> Option<&ManagedCoreFundsAccount> { - match source { - AccountTypePreference::BIP44 => accounts.standard_bip44_accounts.get(&account_index), - AccountTypePreference::BIP32 => accounts.standard_bip32_accounts.get(&account_index), - AccountTypePreference::CoinJoin => accounts.coinjoin_accounts.get(&account_index), - } + source + .account_type(account_index) + .and_then(|at| accounts.funds_account(&at)) } impl FFITransactionBuilder { @@ -710,7 +731,12 @@ pub unsafe extern "C" fn core_wallet_tx_builder_add_inputs_from_outpoints( } let wallet_id = wallet.wallet_id(); - let source: AccountTypePreference = account_type.into(); + let Some(source) = account_type.single_preference() else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "AllSpendable pools multiple accounts; this API addresses exactly one".to_string(), + ); + }; let requested: Vec = if outpoints_len == 0 { Vec::new() diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index caca485c565..9f2739bbff4 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -940,8 +940,9 @@ mod remove_wallet_lifecycle_tests { SignedCoreTransaction::new_for_test( dummy_tx(), 0, - AccountTypePreference::BIP44, - 0, + vec![AccountTypePreference::BIP44 + .account_type(0) + .expect("single account")], 0, None, core.test_generation_marker(), @@ -1195,8 +1196,9 @@ mod remove_wallet_lifecycle_tests { transaction: SignedCoreTransaction::new_for_test( dummy_tx(), 0, - AccountTypePreference::BIP44, - 0, + vec![AccountTypePreference::BIP44 + .account_type(0) + .expect("single account")], 0, None, core.test_generation_marker(), diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index df0f6fc9a6f..bf53c57171f 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -473,8 +473,9 @@ mod destroy_tests { SignedCoreTransaction::new_for_test( dummy_tx(), 0, - AccountTypePreference::BIP44, - 0, + vec![AccountTypePreference::BIP44 + .account_type(0) + .expect("single account")], 0, None, // Bind the finalized payment to this exact wallet diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index efde6e58b83..dc5f50eb8e5 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -57,7 +57,7 @@ pub use wallet::asset_lock::manager::AssetLockManager; pub use wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; pub use wallet::asset_lock::AssetLockFunding; pub use wallet::core::WalletBalance; -pub use wallet::core::{CoreWallet, SignedCoreTransaction}; +pub use wallet::core::{CoreWallet, SignedCoreTransaction, SEND_FUNDING_SOURCES}; pub use wallet::signed_payment_registry::{ RegisterWrongGeneration, ReservationToken, SignedPaymentError, SignedPaymentRegistry, }; diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 85349a31616..dc61dc4a0c7 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -262,6 +262,73 @@ pub(crate) async fn funded_wallet_manager_with_outputs( (Arc::new(RwLock::new(wm)), wallet_id, generation, signer) } +/// Funds BOTH standard families — BIP44 account 0 and BIP32 account 0 — each +/// with its own chain-locked UTXO set, for the pooled-send tests: a spend +/// larger than either family's balance must draw from both. +/// +/// `cfg(test)`-gated like [`RejectFirstBroadcaster`]: only this crate's own +/// unit tests consume it, so a `test-utils`-only build would flag it unused. +#[cfg(test)] +pub(crate) async fn funded_wallet_manager_dual_standard( + bip44_outputs: &[u64], + bip32_outputs: &[u64], +) -> ( + Arc>>, + WalletId, + Arc, + WalletSigner, +) { + let mut ctx = TestWalletContext::new_random(); + + let bip44_address = ctx.receive_address.clone(); + let bip32_address = { + let xpub = ctx + .wallet + .accounts + .standard_bip32_accounts + .get(&0) + .expect("bip32 account") + .account_xpub; + ctx.managed_wallet + .first_bip32_managed_account_mut() + .expect("bip32 managed account") + .next_receive_address(Some(&xpub), true) + .expect("bip32 receive address") + }; + + for (address, outputs, tag) in [ + (&bip44_address, bip44_outputs, 0..1), + (&bip32_address, bip32_outputs, 1..2), + ] { + let funding_tx = Transaction::dummy(address, tag, outputs); + let result = ctx + .check_transaction( + &funding_tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 1, + BlockHash::all_zeros(), + 1_700_000_000, + )), + ) + .await; + assert!(result.is_relevant, "funding tx should be relevant"); + } + + let signer = WalletSigner { + wallet: ctx.wallet.clone(), + }; + let generation = Arc::new(WalletGeneration::new()); + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + generation: Arc::clone(&generation), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + }; + let mut wm = WalletManager::::new(Network::Testnet); + let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); + (Arc::new(RwLock::new(wm)), wallet_id, generation, signer) +} + /// Like [`funded_wallet_manager`] but funds the wallet's CoinJoin account 0 /// (created by `WalletAccountCreationOptions::Default`) with a single spendable /// UTXO. Lets the deferred-payment tests exercise a CoinJoin-funded reservation, diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 0176d661d3a..f0cf529cc41 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -1,6 +1,5 @@ use dashcore::Transaction; use key_wallet::account::account_type::StandardAccountType; -use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::ReservationToken; use super::SignedCoreTransaction; @@ -28,8 +27,7 @@ impl CoreWallet { Err(error) => { if matches!(error, crate::broadcaster::BroadcastError::Rejected { .. }) { self.release_transaction_reservation( - transaction.funding_account_type(), - transaction.funding_account_index(), + transaction.funding_accounts(), transaction.transaction(), transaction.reservation_token(), ) @@ -131,8 +129,7 @@ impl CoreWallet { /// reserved nothing. pub(crate) async fn broadcast_payment_releasing_reservation( &self, - account_type: AccountTypePreference, - account_index: u32, + accounts: &[key_wallet::account::AccountType], transaction: &Transaction, token: Option, ) -> Result { @@ -140,13 +137,8 @@ impl CoreWallet { Ok(txid) => Ok(txid), Err(error) => { if matches!(error, BroadcastError::Rejected { .. }) { - self.release_transaction_reservation( - account_type, - account_index, - transaction, - token, - ) - .await; + self.release_transaction_reservation(accounts, transaction, token) + .await; } Err(error.into()) } @@ -239,7 +231,7 @@ mod tests { let mut builder = TransactionBuilder::new() .set_current_height(current_height) .set_selection_strategy(SelectionStrategy::LargestFirst) - .set_funding(managed_account, account); + .add_funding(managed_account, account); for (addr, amount) in outputs { builder = builder.add_output(addr, *amount); } diff --git a/packages/rs-platform-wallet/src/wallet/core/mod.rs b/packages/rs-platform-wallet/src/wallet/core/mod.rs index 41268256d0d..1ecb26a7141 100644 --- a/packages/rs-platform-wallet/src/wallet/core/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/core/mod.rs @@ -10,5 +10,5 @@ pub mod wallet; pub use balance::WalletBalance; pub use balance_handler::BalanceUpdateHandler; pub use generation::WalletGeneration; -pub use transaction::SignedCoreTransaction; +pub use transaction::{SignedCoreTransaction, SEND_FUNDING_SOURCES}; pub use wallet::CoreWallet; diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 32603873dae..523fa4f44c0 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -9,15 +9,15 @@ use std::collections::HashMap; use std::sync::Arc; use dashcore::{Address, Transaction}; +use key_wallet::account::AccountType; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; -use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionError; use key_wallet::wallet::managed_wallet_info::transaction_builder::{ BuilderError, TransactionBuilder, TransactionSigner, }; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use key_wallet::{Account, DerivationPath, ReservationToken, Utxo}; +use key_wallet::{DerivationPath, ReservationToken, Utxo}; use super::{CoreWallet, WalletGeneration}; use crate::broadcaster::TransactionBroadcaster; @@ -25,6 +25,9 @@ use crate::PlatformWalletError; fn map_builder_error( error: BuilderError, + // Representative source for the typed insufficient-funds error: the FIRST + // preference of the pooled list (BIP44 for a plain send), whose account + // also supplies the change address. account_type: AccountTypePreference, account_index: u32, ) -> PlatformWalletError { @@ -58,8 +61,12 @@ fn map_builder_error( pub struct SignedCoreTransaction { transaction: Transaction, fee: u64, - funding_account_type: AccountTypePreference, - funding_account_index: u32, + /// Every concrete account that contributed funding inputs, in funding + /// order (first supplies the change address). A pooled send spans the + /// standard families and any DashPay receiving accounts, so the + /// release/abandon paths must reconcile the reservation on EACH of them — + /// key-wallet reserves per account, all stamped with the one build token. + funding_accounts: Vec, /// The wallet's `last_processed_height` captured **inside** the funding /// critical section — the exact clock `set_current_height` stamped the /// selected inputs' reservation with, sampled *before* the (potentially @@ -107,12 +114,11 @@ impl SignedCoreTransaction { self.fee } - pub fn funding_account_type(&self) -> AccountTypePreference { - self.funding_account_type - } - - pub fn funding_account_index(&self) -> u32 { - self.funding_account_index + /// Every account that contributed funding inputs (funding order; the + /// first supplied the change address). The release/abandon paths iterate + /// these — the build's reservation lives per-account under one token. + pub fn funding_accounts(&self) -> &[AccountType] { + &self.funding_accounts } /// The `last_processed_height` the funding reservation was stamped with, @@ -155,8 +161,7 @@ impl SignedCoreTransaction { pub(crate) fn into_registered_parts(self) -> RegisteredPaymentParts { RegisteredPaymentParts { transaction: self.transaction, - funding_account_type: self.funding_account_type, - funding_account_index: self.funding_account_index, + funding_accounts: self.funding_accounts, reservation_height: self.reservation_height, reservation_token: self.reservation_token, } @@ -169,8 +174,7 @@ impl SignedCoreTransaction { /// non-`Clone` ownership object exactly once. pub(crate) struct RegisteredPaymentParts { pub(crate) transaction: Transaction, - pub(crate) funding_account_type: AccountTypePreference, - pub(crate) funding_account_index: u32, + pub(crate) funding_accounts: Vec, pub(crate) reservation_height: u32, pub(crate) reservation_token: Option, } @@ -189,8 +193,7 @@ impl SignedCoreTransaction { pub fn new_for_test( transaction: Transaction, fee: u64, - funding_account_type: AccountTypePreference, - funding_account_index: u32, + funding_accounts: Vec, reservation_height: u32, reservation_token: Option, origin_generation: Arc, @@ -198,8 +201,7 @@ impl SignedCoreTransaction { Self { transaction, fee, - funding_account_type, - funding_account_index, + funding_accounts, reservation_height, reservation_token, origin_generation, @@ -207,40 +209,57 @@ impl SignedCoreTransaction { } } -fn account( - wallet: &key_wallet::Wallet, - account_type: AccountTypePreference, - account_index: u32, -) -> Option<&Account> { - match account_type { - AccountTypePreference::BIP44 => wallet.get_bip44_account(account_index), - AccountTypePreference::BIP32 => wallet.get_bip32_account(account_index), - AccountTypePreference::CoinJoin => wallet.get_coinjoin_account(account_index), - } -} - -fn managed_account( +/// The funding sources a plain send pools by default: both standard families +/// plus every DashPay contact-receiving account, in this order — the FIRST +/// source (BIP44) supplies the change address, so change from a pooled send +/// always returns to the transparent primary account. +/// +/// CoinJoin is deliberately absent (spending mixed outputs alongside +/// transparent ones links them and undoes the mixing — the same reasoning as +/// upstream `AccountTypePreference::DEFAULT`), and so are a contact's +/// watch-only `DashpayExternalAccount` coins, which +/// `AllDashpayReceivingFunds` excludes by construction upstream (it selects +/// only the receiving side the local seed can sign). +pub const SEND_FUNDING_SOURCES: [AccountTypePreference; 3] = [ + AccountTypePreference::BIP44, + AccountTypePreference::BIP32, + AccountTypePreference::AllDashpayReceivingFunds, +]; + +/// The concrete accounts `preference` resolves to at `source_index` — the +/// platform mirror of key-wallet's private `account_types_for`: the single +/// account at `source_index` for the standard families, and every DashPay +/// receiving account the selector picks (which span their own indices) for a +/// DashPay source. A set selector matching nothing resolves to an empty list, +/// not an error — a wallet with no contacts still sends from its standard +/// accounts. +fn resolve_source_accounts( accounts: &key_wallet::account::ManagedAccountCollection, - account_type: AccountTypePreference, - account_index: u32, -) -> Option<&ManagedCoreFundsAccount> { - match account_type { - AccountTypePreference::BIP44 => accounts.standard_bip44_accounts.get(&account_index), - AccountTypePreference::BIP32 => accounts.standard_bip32_accounts.get(&account_index), - AccountTypePreference::CoinJoin => accounts.coinjoin_accounts.get(&account_index), - } -} - -fn managed_account_mut( - accounts: &mut key_wallet::account::ManagedAccountCollection, - account_type: AccountTypePreference, - account_index: u32, -) -> Option<&mut ManagedCoreFundsAccount> { - match account_type { - AccountTypePreference::BIP44 => accounts.standard_bip44_accounts.get_mut(&account_index), - AccountTypePreference::BIP32 => accounts.standard_bip32_accounts.get_mut(&account_index), - AccountTypePreference::CoinJoin => accounts.coinjoin_accounts.get_mut(&account_index), - } + preference: AccountTypePreference, + source_index: u32, +) -> Vec { + let (identity, friend) = match preference { + AccountTypePreference::AllDashpayReceivingFunds => (None, None), + AccountTypePreference::DashpayIdentityReceivingFunds { user_identity_id } => { + (Some(user_identity_id), None) + } + AccountTypePreference::DashpayFriendshipReceivingFunds { + user_identity_id, + friend_identity_id, + } => (Some(user_identity_id), Some(friend_identity_id)), + _ => return preference.account_type(source_index).into_iter().collect(), + }; + accounts + .dashpay_receival_accounts + .keys() + .filter(|key| identity.is_none_or(|id| key.user_identity_id == id)) + .filter(|key| friend.is_none_or(|id| key.friend_identity_id == id)) + .map(|key| AccountType::DashpayReceivingFunds { + index: key.index, + user_identity_id: key.user_identity_id, + friend_identity_id: key.friend_identity_id, + }) + .collect() } impl CoreWallet { @@ -249,57 +268,125 @@ impl CoreWallet { pub async fn finalize_transaction( &self, builder: TransactionBuilder, - account_type: AccountTypePreference, - account_index: u32, + // The funding sources to POOL, in order — the first supplies the + // change address. A plain send passes [`SEND_FUNDING_SOURCES`] + // (BIP44 + BIP32 + every DashPay receiving account); a single-element + // list reproduces the old one-account behavior, including its strict + // account-not-found error. `source_index` addresses the standard + // families; DashPay set selectors span their own indices. + sources: &[AccountTypePreference], + source_index: u32, signer: &S, ) -> Result { - let (unsigned, fee, selected, paths, height, reservation_token) = { + let primary = *sources.first().ok_or_else(|| { + PlatformWalletError::TransactionBuild("no funding sources named".into()) + })?; + // A single-source call is an explicit request for THAT account: keep + // the strict not-found error the one-account API had. A pooled call + // skips missing sources (a wallet without a BIP32 account or without + // DashPay contacts still sends) and errors only if NOTHING funds. + let strict = sources.len() == 1; + + let (unsigned, fee, selected, paths, height, reservation_token, funding_accounts) = { let mut manager = self.wallet_manager.write().await; let (wallet, info) = manager .get_wallet_and_info_mut(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound("wallet not found".into()))?; - let account = account(wallet, account_type, account_index) - .cloned() - .ok_or_else(|| { - PlatformWalletError::WalletNotFound(format!( - "wallet account {account_type:?} #{account_index} not found" - )) - })?; let height = info.core_wallet.last_processed_height(); - let managed = - managed_account_mut(&mut info.core_wallet.accounts, account_type, account_index) - .ok_or_else(|| { - PlatformWalletError::WalletNotFound(format!( - "managed account {account_type:?} #{account_index} not found" - )) - })?; - - // `set_funding` observes ReservationSet and `build_unsigned_reserved` - // records its selection AND returns the token stamped onto the - // reserved inputs. There is no await between them and the manager - // write guard prevents another finalizer interleaving. The token - // rides in `SignedCoreTransaction` so a later abandon or rejected - // broadcast releases *only* the inputs this build still owns, even - // if a TTL sweep re-reserved them under a new token meanwhile - // (`dashpay/platform#4185`). + + // Fund from every resolved account, mirroring key-wallet's own + // multi-source fold (`transaction_building::fund`): dedup overlapping + // sources (funding an account twice would offer its UTXOs to + // selection twice), collect the address→path map per contributing + // account for the external signer, and let the FIRST source's first + // account supply the change address. `add_funding` observes each + // account's ReservationSet and `build_unsigned_reserved` records the + // pooled selection AND returns the ONE token stamped onto every + // reserved input across accounts. There is no await in this section + // and the manager write guard prevents another finalizer + // interleaving. The token rides in `SignedCoreTransaction` so a + // later abandon or rejected broadcast releases *only* the inputs + // this build still owns, even if a TTL sweep re-reserved them under + // a new token meanwhile (`dashpay/platform#4185`). + let mut builder = builder.set_current_height(height); + let mut funding_accounts: Vec = Vec::new(); + let mut paths: HashMap = HashMap::new(); + for &preference in sources { + for at in + resolve_source_accounts(&info.core_wallet.accounts, preference, source_index) + { + if funding_accounts.contains(&at) { + continue; + } + let (Some(account), Some(managed)) = ( + wallet.accounts.account_of_type(at), + info.core_wallet.accounts.funds_account_mut(&at), + ) else { + if strict { + return Err(PlatformWalletError::WalletNotFound(format!( + "wallet account {preference:?} #{source_index} not found" + ))); + } + continue; + }; + for utxo in managed.utxos.values() { + if let Some(path) = managed.address_derivation_path(&utxo.address) { + paths.insert(utxo.address.clone(), path); + } + } + builder = builder.add_funding(managed, account); + funding_accounts.push(at); + } + // A strict single-source SET selector (a DashPay preference + // naming zero accounts) also errors — the caller asked for + // exactly those funds. + if strict && funding_accounts.is_empty() { + return Err(PlatformWalletError::WalletNotFound(format!( + "wallet account {preference:?} #{source_index} not found" + ))); + } + } + if funding_accounts.is_empty() { + return Err(PlatformWalletError::WalletNotFound(format!( + "no funding account of any named source at index {source_index}" + ))); + } + let (unsigned, fee, reservation_token) = builder - .set_current_height(height) - .set_funding(managed, &account) .build_unsigned_reserved() - .map_err(|error| map_builder_error(error, account_type, account_index))?; + .map_err(|error| map_builder_error(error, primary, source_index))?; + + // Release across every contributing account on the error paths + // below: the pooled reservation lives per account under the one + // token, and we are still inside the write guard (no sweep can + // interleave), so the plain by-outpoint release is exact. + macro_rules! release_all { + ($accounts:expr, $collection:expr, $unsigned:expr) => { + for at in $accounts.iter() { + if let Some(managed) = $collection.funds_account_mut(at) { + managed.release_reservation($unsigned); + } + } + }; + } let selected: Vec = match unsigned .input .iter() .map(|input| { - managed - .utxos - .get(&input.previous_output) + funding_accounts + .iter() + .find_map(|at| { + info.core_wallet + .accounts + .funds_account(at) + .and_then(|managed| managed.utxos.get(&input.previous_output)) + }) .cloned() .ok_or_else(|| { PlatformWalletError::TransactionBuild(format!( - "selected input {} is no longer in the funding account", + "selected input {} is no longer in any funding account", input.previous_output )) }) @@ -308,33 +395,33 @@ impl CoreWallet { { Ok(selected) => selected, Err(error) => { - managed.release_reservation(&unsigned); + release_all!(funding_accounts, info.core_wallet.accounts, &unsigned); return Err(error); } }; - let paths: HashMap = match selected + // The per-account path collection above covered every UTXO offered + // to selection, so every selected input's address must be present. + if let Some(missing) = selected .iter() - .map(|utxo| { - managed - .address_derivation_path(&utxo.address) - .map(|path| (utxo.address.clone(), path)) - .ok_or_else(|| { - PlatformWalletError::TransactionBuild(format!( - "no derivation path for selected input address {}", - utxo.address - )) - }) - }) - .collect::>() + .find(|utxo| !paths.contains_key(&utxo.address)) { - Ok(paths) => paths, - Err(error) => { - managed.release_reservation(&unsigned); - return Err(error); - } - }; + let error = PlatformWalletError::TransactionBuild(format!( + "no derivation path for selected input address {}", + missing.address + )); + release_all!(funding_accounts, info.core_wallet.accounts, &unsigned); + return Err(error); + } - (unsigned, fee, selected, paths, height, reservation_token) + ( + unsigned, + fee, + selected, + paths, + height, + reservation_token, + funding_accounts, + ) }; let signed = match signer @@ -351,8 +438,7 @@ impl CoreWallet { // inputs under a new token. Release owner-guarded so we free // only what this build still owns. self.release_transaction_reservation( - account_type, - account_index, + &funding_accounts, &unsigned, reservation_token, ) @@ -364,8 +450,7 @@ impl CoreWallet { Ok(SignedCoreTransaction { transaction: signed, fee, - funding_account_type: account_type, - funding_account_index: account_index, + funding_accounts, reservation_height: height, reservation_token, // Capture the finalizing wallet's generation identity so the @@ -378,8 +463,7 @@ impl CoreWallet { /// Release a finalized transaction that the caller has chosen not to send. pub async fn abandon_transaction(&self, transaction: &SignedCoreTransaction) { self.release_transaction_reservation( - transaction.funding_account_type, - transaction.funding_account_index, + &transaction.funding_accounts, &transaction.transaction, transaction.reservation_token, ) @@ -398,8 +482,9 @@ impl CoreWallet { /// path is never reached for a funded finalize, which always reserves. pub(crate) async fn release_transaction_reservation( &self, - account_type: AccountTypePreference, - account_index: u32, + // Every account the build funded from — the pooled reservation lives + // per account under the one `token`, so each must reconcile. + accounts: &[AccountType], transaction: &Transaction, token: Option, ) { @@ -423,8 +508,7 @@ impl CoreWallet { let Some(info) = manager.get_wallet_info(&self.wallet_id) else { tracing::warn!( wallet_id = %hex::encode(self.wallet_id), - ?account_type, - account_index, + ?accounts, "could not release finalized Core transaction reservation: wallet not found" ); return; @@ -435,30 +519,31 @@ impl CoreWallet { // original generation's reservation ceased to exist with it. tracing::warn!( wallet_id = %hex::encode(self.wallet_id), - ?account_type, - account_index, + ?accounts, "skipping reservation release: wallet was re-created under the same id \ (different generation) since the token was minted" ); return; } - match managed_account(&info.core_wallet.accounts, account_type, account_index) { - // Owner-guarded when the build stamped a token: even within this - // generation, a TTL sweep between build and release could have - // re-reserved the same outpoints under a new token, and an - // unconditional release would free that newer reservation. With the - // token key-wallet frees only inputs this build still owns. `None` - // (no reservation taken) falls back to the unconditional release. - Some(managed) => match token { - Some(token) => managed.release_reservation_if_owner(transaction, token), - None => managed.release_reservation(transaction), - }, - None => tracing::warn!( - wallet_id = %hex::encode(self.wallet_id), - ?account_type, - account_index, - "could not release finalized Core transaction reservation: account not found" - ), + for at in accounts { + match info.core_wallet.accounts.funds_account(at) { + // Owner-guarded when the build stamped a token: even within this + // generation, a TTL sweep between build and release could have + // re-reserved the same outpoints under a new token, and an + // unconditional release would free that newer reservation. With + // the token key-wallet frees only inputs this build still owns + // in THIS account's set. `None` (no reservation taken) falls + // back to the unconditional release. + Some(managed) => match token { + Some(token) => managed.release_reservation_if_owner(transaction, token), + None => managed.release_reservation(transaction), + }, + None => tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + account = %at, + "could not release finalized Core transaction reservation: account not found" + ), + } } } } @@ -479,8 +564,8 @@ mod tests { use crate::broadcaster::TransactionBroadcaster; use crate::test_support::{ - funded_wallet_manager, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, - AlwaysRejectedBroadcaster, WalletSigner, + funded_wallet_manager, funded_wallet_manager_dual_standard, AlwaysMaybeSentBroadcaster, + AlwaysOkBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, }; use crate::wallet::core::CoreWallet; use crate::PlatformWalletError; @@ -504,6 +589,85 @@ mod tests { ) } + /// THE POOLED SEND (rust-dashcore#925/#929): `SEND_FUNDING_SOURCES` draws + /// from BOTH standard families when neither alone covers the payment, + /// records every contributing account on the ownership object, tolerates + /// the wallet having no DashPay accounts (the `AllDashpayReceivingFunds` + /// selector contributes nothing rather than erroring), and an abandon + /// releases the reservation on EVERY contributing account so an immediate + /// identical rebuild succeeds. + #[tokio::test] + async fn pooled_send_spans_families_and_abandon_releases_all() { + let (manager, wallet_id, generation, signer) = + funded_wallet_manager_dual_standard(&[700_000], &[700_000]).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + manager, + wallet_id, + Arc::new(AlwaysOkBroadcaster), + generation, + ); + + // 1_000_000 exceeds either family's 700_000, so selection must pool. + let finalized = core + .finalize_transaction( + payment_builder(40), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await + .expect("pooled finalize must fund from both standard families"); + assert_eq!( + finalized.funding_accounts().len(), + 2, + "both standard families must contribute (and be recorded for release)" + ); + assert!( + finalized.transaction().input.len() >= 2, + "a payment above either family's balance needs inputs from both" + ); + + // Abandon must release BOTH accounts' reservations: an identical + // rebuild can only succeed if every pooled input returned to the pool. + core.abandon_transaction(&finalized).await; + let rebuilt = core + .finalize_transaction( + payment_builder(41), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await + .expect("abandon must release every contributing account's reservation"); + core.abandon_transaction(&rebuilt).await; + } + + /// A single-source call keeps the strict one-account contract: naming a + /// family with no funded account at the index errors instead of silently + /// funding from elsewhere. + #[tokio::test] + async fn single_source_missing_account_still_errors() { + let (core, signer) = core( + StandardAccountType::BIP44Account, + Arc::new(AlwaysOkBroadcaster), + ) + .await; + let result = core + .finalize_transaction( + payment_builder(50), + &[AccountTypePreference::BIP44], + 7, // no account at index 7 + &signer, + ) + .await; + assert!( + matches!(result, Err(PlatformWalletError::WalletNotFound(_))), + "explicit single-source misses must stay strict, got {result:?}" + ); + } + fn payment_builder(tag: u8) -> TransactionBuilder { TransactionBuilder::new().add_output( &DashAddress::dummy(Network::Testnet, usize::from(tag)), @@ -526,7 +690,7 @@ mod tests { barrier.wait().await; core.finalize_transaction( payment_builder(tag), - preference(account_type), + &[preference(account_type)], 0, &signer, ) @@ -585,7 +749,7 @@ mod tests { let validation = core .finalize_transaction( TransactionBuilder::new(), - preference(account_type), + &[preference(account_type)], 0, &signer, ) @@ -598,7 +762,7 @@ mod tests { let signing = core .finalize_transaction( payment_builder(20), - preference(account_type), + &[preference(account_type)], 0, &FailingSigner, ) @@ -609,7 +773,7 @@ mod tests { )); assert!(core - .finalize_transaction(payment_builder(21), preference(account_type), 0, &signer) + .finalize_transaction(payment_builder(21), &[preference(account_type)], 0, &signer) .await .is_ok()); } @@ -620,13 +784,13 @@ mod tests { let (rejection_core, signer) = core(account_type, Arc::new(AlwaysRejectedBroadcaster)).await; let abandoned = rejection_core - .finalize_transaction(payment_builder(30), preference(account_type), 0, &signer) + .finalize_transaction(payment_builder(30), &[preference(account_type)], 0, &signer) .await .expect("finalize for abandon"); rejection_core.abandon_transaction(&abandoned).await; let rejected = rejection_core - .finalize_transaction(payment_builder(31), preference(account_type), 0, &signer) + .finalize_transaction(payment_builder(31), &[preference(account_type)], 0, &signer) .await .expect("reservation released by abandon"); assert!(matches!( @@ -636,7 +800,7 @@ mod tests { Err(PlatformWalletError::TransactionBroadcast(_)) )); assert!(rejection_core - .finalize_transaction(payment_builder(32), preference(account_type), 0, &signer) + .finalize_transaction(payment_builder(32), &[preference(account_type)], 0, &signer) .await .is_ok()); @@ -645,7 +809,7 @@ mod tests { let ambiguous = ambiguous_core .finalize_transaction( payment_builder(33), - preference(account_type), + &[preference(account_type)], 0, &ambiguous_signer, ) @@ -661,7 +825,7 @@ mod tests { ambiguous_core .finalize_transaction( payment_builder(34), - preference(account_type), + &[preference(account_type)], 0, &ambiguous_signer, ) diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index fbf2c7684e0..a3d829b0920 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -150,40 +150,32 @@ impl CoreWallet { PlatformWalletError::WalletNotFound("Wallet not found in wallet manager".to_string()) })?; - let xpub = match account_type { - AccountTypePreference::BIP44 => wallet.get_bip44_account(account_index), - AccountTypePreference::BIP32 => wallet.get_bip32_account(account_index), - AccountTypePreference::CoinJoin => wallet.get_coinjoin_account(account_index), - } - .map(|a| a.account_xpub) - .ok_or_else(|| { + // Gap limits are a per-account setting, so a set-naming DashPay + // selector (identity-wide / all) has no single target here. + let concrete = account_type.account_type(account_index).ok_or_else(|| { PlatformWalletError::WalletNotFound(format!( - "wallet account {account_type:?} #{account_index} not found" + "{account_type:?} names a set of accounts; set a gap limit per account" )) })?; + let xpub = wallet + .accounts + .account_of_type(concrete) + .map(|a| a.account_xpub) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "wallet account {account_type:?} #{account_index} not found" + )) + })?; - let account = match account_type { - AccountTypePreference::BIP44 => info - .core_wallet - .accounts - .standard_bip44_accounts - .get_mut(&account_index), - AccountTypePreference::BIP32 => info - .core_wallet - .accounts - .standard_bip32_accounts - .get_mut(&account_index), - AccountTypePreference::CoinJoin => info - .core_wallet - .accounts - .coinjoin_accounts - .get_mut(&account_index), - } - .ok_or_else(|| { - PlatformWalletError::WalletNotFound(format!( - "managed account {account_type:?} #{account_index} not found" - )) - })?; + let account = info + .core_wallet + .accounts + .funds_account_mut(&concrete) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "managed account {account_type:?} #{account_index} not found" + )) + })?; account .set_gap_limit(gap_limit, &KeySource::Public(xpub)) 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 f2f65ace31f..5ed5a805262 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1222,7 +1222,7 @@ impl DashPayView<'_, B> { let builder = TransactionBuilder::new() .set_current_height(current_height) .set_selection_strategy(SelectionStrategy::LargestFirst) - .set_funding(managed_account, account) + .add_funding(managed_account, account) .add_output(&payment_address, amount_duffs); // Sign through the injected signer (blanket diff --git a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs index db385d55c61..a7a29620782 100644 --- a/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs +++ b/packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs @@ -71,7 +71,6 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use dashcore::{Transaction, Txid}; -use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; // key-wallet's UTXO-reservation token, distinct from this registry's own // `ReservationToken` (the u64 payment handle below). Aliased so the two never // blur: the funding token identifies the reserved *inputs* for an owner-guarded @@ -249,16 +248,13 @@ struct RegisteredPayment { core: CoreWallet, /// The signed transaction to broadcast. tx: Transaction, - /// The releasable funding-account handle — the account whose reservation - /// `finalize` took and which a rejected broadcast or an explicit release - /// must reconcile. An [`AccountTypePreference`] (not the narrower - /// `StandardAccountType`) so CoinJoin-funded deferred payments retain a - /// releasable handle too: `finalize` reserves the selected inputs for EVERY - /// account variant, so a CoinJoin token must be able to release them - /// immediately on rejection/abandon rather than stranding them until the - /// key-wallet TTL backstop. - account_type: AccountTypePreference, - account_index: u32, + /// Every account whose reservation `finalize` took — a pooled build spans + /// the standard families and DashPay receiving accounts, and a rejected + /// broadcast or an explicit release must reconcile EACH of them (the one + /// build token stamps every account's reserved inputs). Concrete + /// [`AccountType`]s so CoinJoin- and DashPay-funded deferred payments + /// retain releasable handles too. + funding_accounts: Vec, /// Wallet `last_processed_height` captured inside the funding critical /// section — the exact clock `finalize_transaction` stamps the funding /// reservation with (`SignedCoreTransaction::reservation_height`). Compared @@ -404,8 +400,7 @@ impl SignedPaymentRegistry { RegisteredPayment { core, tx: parts.transaction, - account_type: parts.funding_account_type, - account_index: parts.funding_account_index, + funding_accounts: parts.funding_accounts, registered_height: parts.reservation_height, funding_reservation_token: parts.reservation_token, }, @@ -530,8 +525,7 @@ impl SignedPaymentRegistry { let txid = entry .core .broadcast_payment_releasing_reservation( - entry.account_type, - entry.account_index, + &entry.funding_accounts, &entry.tx, entry.funding_reservation_token, ) @@ -566,8 +560,7 @@ impl SignedPaymentRegistry { entry .core .release_transaction_reservation( - entry.account_type, - entry.account_index, + &entry.funding_accounts, &entry.tx, entry.funding_reservation_token, ) @@ -824,7 +817,7 @@ mod tests { let mut builder = TransactionBuilder::new() .set_current_height(current_height) .set_selection_strategy(SelectionStrategy::LargestFirst) - .set_funding(managed_account, account); + .add_funding(managed_account, account); for (addr, amount) in outputs { builder = builder.add_output(addr, *amount); } @@ -837,8 +830,9 @@ mod tests { Ok(SignedCoreTransaction::new_for_test( tx, fee, - preference(account_type), - account_index, + vec![preference(account_type) + .account_type(account_index) + .expect("standard preference resolves to one account")], current_height, reservation_token, // Stamp the finalizing generation so registering through this same @@ -961,7 +955,7 @@ mod tests { let finalized = core .finalize_transaction( sweep_builder(&recipient), - AccountTypePreference::CoinJoin, + &[AccountTypePreference::CoinJoin], 0, &signer, ) @@ -976,7 +970,7 @@ mod tests { let blocked = core .finalize_transaction( sweep_builder(&recipient), - AccountTypePreference::CoinJoin, + &[AccountTypePreference::CoinJoin], 0, &signer, ) @@ -997,7 +991,7 @@ mod tests { let rebuilt = core .finalize_transaction( sweep_builder(&recipient), - AccountTypePreference::CoinJoin, + &[AccountTypePreference::CoinJoin], 0, &signer, ) @@ -1878,7 +1872,7 @@ mod tests { let finalized = core .finalize_transaction( payment_builder(&outputs), - AccountTypePreference::BIP44, + &[AccountTypePreference::BIP44], 0, &signer, ) @@ -1900,7 +1894,7 @@ mod tests { let retaken = core .finalize_transaction( payment_builder(&outputs), - AccountTypePreference::BIP44, + &[AccountTypePreference::BIP44], 0, &signer, ) @@ -1926,7 +1920,7 @@ mod tests { let third = core .finalize_transaction( payment_builder(&outputs), - AccountTypePreference::BIP44, + &[AccountTypePreference::BIP44], 0, &signer, ) diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index a06b234d344..1984e1f6b93 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3120,6 +3120,7 @@ fn core_account_type(value: jni::sys::jint) -> Option Some(platform_wallet_ffi::CoreAccountTypeFFI::BIP44), 1 => Some(platform_wallet_ffi::CoreAccountTypeFFI::BIP32), 2 => Some(platform_wallet_ffi::CoreAccountTypeFFI::CoinJoin), + 3 => Some(platform_wallet_ffi::CoreAccountTypeFFI::AllSpendable), _ => None, } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift index ab59b4a90c9..ce22efa0704 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift @@ -144,12 +144,19 @@ public final class CoreTransactionBuilder { case bip44 case bip32 case coinJoin + /// Pool every spendable transparent source: BIP44 + BIP32 + all + /// DashPay contact-receiving accounts. Change returns to BIP44 (the + /// first pooled source). CoinJoin stays out (separate privacy + /// domain), as do a contact's watch-only external coins. The default + /// selector for a plain send. + case allSpendable var ffi: CoreAccountTypeFFI { switch self { case .bip44: return CORE_ACCOUNT_TYPE_FFI_BIP44 case .bip32: return CORE_ACCOUNT_TYPE_FFI_BIP32 case .coinJoin: return CORE_ACCOUNT_TYPE_FFI_COIN_JOIN + case .allSpendable: return CORE_ACCOUNT_TYPE_FFI_ALL_SPENDABLE } } } @@ -311,8 +318,8 @@ public final class CoreTransactionBuilder { /// then sign after Rust has released the wallet-manager lock. public func finalizeAtomic( wallet: ManagedPlatformWallet, - accountType: AccountType, - accountIndex: UInt32 + accountType: AccountType = .allSpendable, + accountIndex: UInt32 = 0 ) throws -> FinalizedCoreTransaction { guard !consumed else { throw PlatformWalletError.unknown("CoreTransactionBuilder already consumed") From f4e94c7ea54dac74906250f32bbddca25205d8fd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 7 Aug 2026 20:24:22 +0700 Subject: [PATCH 2/5] test(platform-wallet): prove pooled sends actually spend DashPay contact funds The pooled-send test covered the two standard families but only asserted that an EMPTY DashPay selector contributes nothing, so every contact-account lookup in the pooled path could have resolved None and the feature would have silently degraded to a BIP44+BIP32 send with no test failing. Add a fixture that builds a real DashpayReceivingFunds account the way DashPayView::register_contact_account does (DIP-15 xpub, Account in the key collection, funds-bearing managed account in the managed collection, minus the persistence round the fixture cannot reach) and funds it with a chain-locked UTXO. The new test spends more than BIP44 alone holds, then asserts the contact account is recorded as a contributor, that every pooled input is signed (exercising the DIP-15 Normal256 derivation path through the signer), and that abandon releases the contact account's reservation too. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet/src/test_support.rs | 107 ++++++++++++++++++ .../src/wallet/core/transaction.rs | 61 +++++++++- 2 files changed, 166 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index dc61dc4a0c7..19f64f0d534 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -329,6 +329,113 @@ pub(crate) async fn funded_wallet_manager_dual_standard( (Arc::new(RwLock::new(wm)), wallet_id, generation, signer) } +/// Funds BIP44 account 0 and a real `DashpayReceivingFunds` contact account, +/// so the pooled-send tests can prove that contact funds are actually SPENT — +/// the point of pulling `AllDashpayReceivingFunds` into `SEND_FUNDING_SOURCES`. +/// +/// The contact account is built exactly as `DashPayView::register_contact_account` +/// builds it (DIP-15 xpub, `Account` in the key collection, funds-bearing managed +/// account in the managed collection) minus the persistence round, which needs a +/// full manager the fixture does not have. Returns the contact's `AccountType` +/// so the test can assert on the recorded funding accounts by identity. +#[cfg(test)] +pub(crate) async fn funded_wallet_manager_with_contact( + bip44_outputs: &[u64], + contact_outputs: &[u64], +) -> ( + Arc>>, + WalletId, + Arc, + WalletSigner, + key_wallet::account::AccountType, +) { + use dpp::identifier::Identifier; + use key_wallet::account::AccountType; + use key_wallet::managed_account::ManagedCoreFundsAccount; + + let mut ctx = TestWalletContext::new_random(); + let bip44_address = ctx.receive_address.clone(); + + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let account_type = AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + let account_xpub = crate::wallet::identity::crypto::dip14::derive_contact_xpub( + &ctx.wallet, + Network::Testnet, + 0, + &owner, + &contact, + ) + .expect("derive contact xpub") + .xpub; + + let mut managed = ManagedCoreFundsAccount::from_account(&key_wallet::Account { + parent_wallet_id: Some(ctx.wallet.wallet_id), + account_type, + network: Network::Testnet, + account_xpub, + is_watch_only: false, + }); + // DashPay accounts are non-standard: one external pool via + // `next_address_with_info`, not the standard receive/change split. + let contact_address = managed + .next_address_with_info(Some(&account_xpub), true) + .expect("contact receive address") + .address; + ctx.wallet + .add_account(account_type, Some(account_xpub)) + .expect("add contact account to key collection"); + ctx.managed_wallet + .accounts + .insert_funds_bearing_account(managed) + .expect("insert managed contact account"); + + for (address, outputs, tag) in [ + (&bip44_address, bip44_outputs, 0..1), + (&contact_address, contact_outputs, 1..2), + ] { + let funding_tx = Transaction::dummy(address, tag, outputs); + let result = ctx + .check_transaction( + &funding_tx, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 1, + BlockHash::all_zeros(), + 1_700_000_000, + )), + ) + .await; + assert!( + result.is_relevant, + "funding tx should be relevant (the contact account's pool must be monitored)" + ); + } + + let signer = WalletSigner { + wallet: ctx.wallet.clone(), + }; + let generation = Arc::new(WalletGeneration::new()); + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + generation: Arc::clone(&generation), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + }; + let mut wm = WalletManager::::new(Network::Testnet); + let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); + ( + Arc::new(RwLock::new(wm)), + wallet_id, + generation, + signer, + account_type, + ) +} + /// Like [`funded_wallet_manager`] but funds the wallet's CoinJoin account 0 /// (created by `WalletAccountCreationOptions::Default`) with a single spendable /// UTXO. Lets the deferred-payment tests exercise a CoinJoin-funded reservation, diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 523fa4f44c0..7c270c16575 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -564,8 +564,9 @@ mod tests { use crate::broadcaster::TransactionBroadcaster; use crate::test_support::{ - funded_wallet_manager, funded_wallet_manager_dual_standard, AlwaysMaybeSentBroadcaster, - AlwaysOkBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, + funded_wallet_manager, funded_wallet_manager_dual_standard, + funded_wallet_manager_with_contact, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, + AlwaysRejectedBroadcaster, WalletSigner, }; use crate::wallet::core::CoreWallet; use crate::PlatformWalletError; @@ -644,6 +645,62 @@ mod tests { core.abandon_transaction(&rebuilt).await; } + /// The DashPay half of `SEND_FUNDING_SOURCES`, end to end: a payment larger + /// than BIP44 alone holds must reach into a real `DashpayReceivingFunds` + /// contact account, sign its inputs (DIP-15 `Normal256` derivation path), + /// and record that account for release. Without this, every lookup in the + /// pooled path could resolve `None` for contact accounts and the feature + /// would silently degrade to a BIP44+BIP32 send. + #[tokio::test] + async fn pooled_send_spends_dashpay_contact_funds() { + let (manager, wallet_id, generation, signer, contact_account) = + funded_wallet_manager_with_contact(&[700_000], &[700_000]).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + manager, + wallet_id, + Arc::new(AlwaysOkBroadcaster), + generation, + ); + + let finalized = core + .finalize_transaction( + payment_builder(60), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await + .expect("pooled finalize must reach contact funds"); + assert!( + finalized.funding_accounts().contains(&contact_account), + "the contact account must be recorded as a contributor, got {:?}", + finalized.funding_accounts() + ); + assert!( + finalized + .transaction() + .input + .iter() + .all(|input| !input.script_sig.is_empty()), + "every pooled input must be signed, including the DIP-15 contact input" + ); + + // And releasing must reach the contact account too. + core.abandon_transaction(&finalized).await; + let rebuilt = core + .finalize_transaction( + payment_builder(61), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await + .expect("abandon must release the contact account's reservation"); + core.abandon_transaction(&rebuilt).await; + } + /// A single-source call keeps the strict one-account contract: naming a /// family with no funded account at the index errors instead of silently /// funding from elsewhere. From 329147a7aa38b5bf03be9b45db5771080ec7253e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 7 Aug 2026 21:59:55 +0700 Subject: [PATCH 3/5] fix(platform-wallet): report pooled contributors, pooled shortfalls, and guard duplicate prevouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the pooled send path. Contributors vs offered accounts. `funding_accounts` was pushed when an account's UTXOs were OFFERED to selection, so a transaction funded entirely from BIP44 still reported BIP32 and every DashPay contact as a contributor — violating the field's documented contract and scaling release and registry bookkeeping with the address book. Membership now rides a HashSet (the linear `contains` made pooled funding quadratic in contact count), the ordered offered list drives build-time cleanup only, and the list stored on the transaction is derived by mapping each selected prevout back to the account that owns it. Pooled shortfalls. A pooled build's `available`/`required` describe the union of every offered source, so attributing them to the first preference reported aggregate figures as "insufficient funds on BIP44 account 0" — and could name BIP44 even when no such account existed and it was skipped. Single-source builds keep the account-specific error; pooled builds get `CorePooledInsufficientFunds`, carrying the source list instead of one account. Both map to the same FFI code, so hosts classify a shortfall exactly as before. Duplicate prevouts. Additive funding can offer an outpoint the builder was already seeded with through `add_inputs` (the `add_inputs_from_outpoints` FFI draws from the wallet's own account), and coin selection does not deduplicate, so the transaction could spend one prevout twice — invalid, and Core rejects it. Fixed upstream in dashpay/rust-dashcore#931; asserted here as well rather than handing a signer and then the network a transaction that cannot confirm. Swift `buildSignedPayment` defaulted to `.bip44` while its Kotlin counterpart defaults to pooled, so Swift callers omitting `accountType` could hit an insufficient-funds error with a sufficient pooled balance. Co-Authored-By: Claude Opus 5 --- packages/rs-platform-wallet-ffi/src/error.rs | 7 +- packages/rs-platform-wallet/src/error.rs | 21 +++ .../src/wallet/core/transaction.rs | 137 ++++++++++++++---- .../rs-unified-sdk-jni/src/wallet_manager.rs | 14 +- .../ManagedPlatformWallet.swift | 6 +- 5 files changed, 149 insertions(+), 36 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index d27c5a822ec..812df99f36d 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -456,7 +456,12 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AddressNonceMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAddressNonceMismatch } - PlatformWalletError::CoreInsufficientFunds { .. } => { + // Both shapes are "the wallet cannot cover this payment"; hosts + // classify and retry them identically, so the pooled variant rides + // the same code rather than forcing every host to learn a second + // insufficient-funds value. + PlatformWalletError::CoreInsufficientFunds { .. } + | PlatformWalletError::CorePooledInsufficientFunds { .. } => { PlatformWalletFFIResultCode::ErrorCoreInsufficientFunds } PlatformWalletError::AssetLockNotTracked(..) => { diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 4af28baf1a0..b33dc25713c 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -194,6 +194,27 @@ pub enum PlatformWalletError { required: Option, }, + /// Atomic Core finalization could not select enough unreserved funds for a + /// POOLED build (more than one funding source offered). + /// + /// Separate from [`CoreInsufficientFunds`] because `available`/`required` + /// describe the UNION of every offered source: attributing them to one + /// account would misreport the figures and could name a source that + /// contributed nothing — or that the wallet does not even have. FFI maps + /// both variants to the same host-facing insufficient-funds code, so hosts + /// classify a shortfall identically either way. + /// + /// [`CoreInsufficientFunds`]: Self::CoreInsufficientFunds + #[error( + "insufficient unreserved Core funds across the pooled funding sources \ + {sources:?}: available {available:?}, required {required:?}" + )] + CorePooledInsufficientFunds { + sources: Vec, + available: Option, + required: Option, + }, + #[error("no spendable inputs available on {account_type} account {account_index}: {context}")] NoSpendableInputs { account_type: StandardAccountType, diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 7c270c16575..2987d01518b 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -5,10 +5,10 @@ //! lock is dropped, so an external signer may call back into a host mnemonic //! resolver without pinning wallet state. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use dashcore::{Address, Transaction}; +use dashcore::{Address, OutPoint, Transaction}; use key_wallet::account::AccountType; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionError; @@ -23,14 +23,21 @@ use super::{CoreWallet, WalletGeneration}; use crate::broadcaster::TransactionBroadcaster; use crate::PlatformWalletError; -fn map_builder_error( - error: BuilderError, - // Representative source for the typed insufficient-funds error: the FIRST - // preference of the pooled list (BIP44 for a plain send), whose account - // also supplies the change address. - account_type: AccountTypePreference, - account_index: u32, -) -> PlatformWalletError { +/// What funded (or failed to fund) a build, for attributing a shortfall. +/// +/// A single-source build can name its account. A pooled build cannot: the +/// builder's `available`/`required` describe the UNION of every offered source, +/// so naming one of them would misreport the figures and could point at a +/// source that contributed nothing. +enum FundingContext<'a> { + Single { + preference: AccountTypePreference, + index: u32, + }, + Pooled(&'a [AccountTypePreference]), +} + +fn map_builder_error(error: BuilderError, context: FundingContext<'_>) -> PlatformWalletError { let funds = match error { BuilderError::InsufficientFunds { available, @@ -44,11 +51,20 @@ fn map_builder_error( _ => None, }; if let Some((available, required)) = funds { - return PlatformWalletError::CoreInsufficientFunds { - account_type, - account_index, - available, - required, + return match context { + FundingContext::Single { preference, index } => { + PlatformWalletError::CoreInsufficientFunds { + account_type: preference, + account_index: index, + available, + required, + } + } + FundingContext::Pooled(sources) => PlatformWalletError::CorePooledInsufficientFunds { + sources: sources.to_vec(), + available, + required, + }, }; } PlatformWalletError::TransactionBuild(error.to_string()) @@ -310,13 +326,19 @@ impl CoreWallet { // this build still owns, even if a TTL sweep re-reserved them under // a new token meanwhile (`dashpay/platform#4185`). let mut builder = builder.set_current_height(height); - let mut funding_accounts: Vec = Vec::new(); + // Accounts whose UTXOs were OFFERED to selection, in funding order. + // Not the same as the accounts that end up contributing inputs — + // selection may take nothing from most of them — so this drives + // build-time cleanup only, and the contributor list stored on the + // transaction is derived from the selected inputs below. + let mut offered_accounts: Vec = Vec::new(); + let mut offered_seen: HashSet = HashSet::new(); let mut paths: HashMap = HashMap::new(); for &preference in sources { for at in resolve_source_accounts(&info.core_wallet.accounts, preference, source_index) { - if funding_accounts.contains(&at) { + if !offered_seen.insert(at) { continue; } let (Some(account), Some(managed)) = ( @@ -336,26 +358,34 @@ impl CoreWallet { } } builder = builder.add_funding(managed, account); - funding_accounts.push(at); + offered_accounts.push(at); } // A strict single-source SET selector (a DashPay preference // naming zero accounts) also errors — the caller asked for // exactly those funds. - if strict && funding_accounts.is_empty() { + if strict && offered_accounts.is_empty() { return Err(PlatformWalletError::WalletNotFound(format!( "wallet account {preference:?} #{source_index} not found" ))); } } - if funding_accounts.is_empty() { + if offered_accounts.is_empty() { return Err(PlatformWalletError::WalletNotFound(format!( "no funding account of any named source at index {source_index}" ))); } + let funding_context = if strict { + FundingContext::Single { + preference: primary, + index: source_index, + } + } else { + FundingContext::Pooled(sources) + }; let (unsigned, fee, reservation_token) = builder .build_unsigned_reserved() - .map_err(|error| map_builder_error(error, primary, source_index))?; + .map_err(|error| map_builder_error(error, funding_context))?; // Release across every contributing account on the error paths // below: the pooled reservation lives per account under the one @@ -371,19 +401,32 @@ impl CoreWallet { }; } + // Map every selected input back to the account that owns it. That + // mapping — not the offered list — is what the transaction carries: + // selection routinely takes nothing from most offered sources, and + // a `funding_accounts` naming every contact would make release and + // registry bookkeeping scale with the address book while claiming + // contributions that never happened. + let mut contributors: Vec = Vec::new(); let selected: Vec = match unsigned .input .iter() .map(|input| { - funding_accounts + offered_accounts .iter() .find_map(|at| { - info.core_wallet - .accounts - .funds_account(at) - .and_then(|managed| managed.utxos.get(&input.previous_output)) + let utxo = + info.core_wallet.accounts.funds_account(at).and_then( + |managed| managed.utxos.get(&input.previous_output), + )?; + Some((*at, utxo.clone())) + }) + .map(|(at, utxo)| { + if !contributors.contains(&at) { + contributors.push(at); + } + utxo }) - .cloned() .ok_or_else(|| { PlatformWalletError::TransactionBuild(format!( "selected input {} is no longer in any funding account", @@ -395,10 +438,29 @@ impl CoreWallet { { Ok(selected) => selected, Err(error) => { - release_all!(funding_accounts, info.core_wallet.accounts, &unsigned); + release_all!(offered_accounts, info.core_wallet.accounts, &unsigned); return Err(error); } }; + // Duplicate prevouts make a transaction invalid (Core rejects it), + // and additive funding is the shape that can produce them — an + // outpoint seeded by `add_inputs` that a funding account also + // offers. `add_funding` filters those (rust-dashcore#931); this + // asserts the invariant here too rather than handing a signer, and + // then the network, a transaction that cannot confirm. + let mut prevouts: HashSet = HashSet::new(); + if let Some(duplicate) = unsigned + .input + .iter() + .find(|input| !prevouts.insert(input.previous_output)) + { + let error = PlatformWalletError::TransactionBuild(format!( + "built transaction spends {} twice", + duplicate.previous_output + )); + release_all!(offered_accounts, info.core_wallet.accounts, &unsigned); + return Err(error); + } // The per-account path collection above covered every UTXO offered // to selection, so every selected input's address must be present. if let Some(missing) = selected @@ -409,7 +471,7 @@ impl CoreWallet { "no derivation path for selected input address {}", missing.address )); - release_all!(funding_accounts, info.core_wallet.accounts, &unsigned); + release_all!(offered_accounts, info.core_wallet.accounts, &unsigned); return Err(error); } @@ -420,7 +482,7 @@ impl CoreWallet { paths, height, reservation_token, - funding_accounts, + contributors, ) }; @@ -686,6 +748,21 @@ mod tests { .all(|input| !input.script_sig.is_empty()), "every pooled input must be signed, including the DIP-15 contact input" ); + // BIP32 account 0 exists on the test wallet but holds nothing, so it is + // OFFERED to selection and contributes no input. Contributors are + // derived from the selected prevouts, so it must not be recorded — + // otherwise release and registry bookkeeping would claim accounts that + // never funded anything and scale with the address book. + assert!( + !finalized + .funding_accounts() + .contains(&key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP32Account, + }), + "an offered-but-unselected account must not be recorded as a contributor, got {:?}", + finalized.funding_accounts() + ); // And releasing must reach the contact account too. core.abandon_transaction(&finalized).await; diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 1984e1f6b93..bb5d7a1539e 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3111,10 +3111,16 @@ fn ffi_network(value: jni::sys::jint) -> dash_network::ffi::FFINetwork { } } -/// Map the Kotlin core account-type int (0 BIP44, 1 BIP32, 2 CoinJoin) to -/// `CoreAccountTypeFFI`. Returns `None` for an out-of-range / negative value -/// so the caller can throw `ErrorInvalidParameter` rather than bit-casting -/// into an undefined discriminant. +/// Map the Kotlin core account-type int (0 BIP44, 1 BIP32, 2 CoinJoin, +/// 3 AllSpendable) to `CoreAccountTypeFFI`. Returns `None` for an +/// out-of-range / negative value so the caller can throw +/// `ErrorInvalidParameter` rather than bit-casting into an undefined +/// discriminant. +/// +/// 3 is the pooled selector (BIP44 + BIP32 + every DashPay receiving account, +/// change to BIP44) and is the default for sends; the single-account APIs +/// (gap limits, per-account UTXO listing) reject it with +/// `ErrorInvalidParameter` since they address exactly one account. fn core_account_type(value: jni::sys::jint) -> Option { match value { 0 => Some(platform_wallet_ffi::CoreAccountTypeFFI::BIP44), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 3c861030d06..73bb1f8db73 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -4219,12 +4219,16 @@ extension ManagedPlatformWallet { /// this call and `broadcastSigned` drops it on restart and the UTXOs become /// spendable again. /// + /// Funding defaults to `.allSpendable`: BIP44 + BIP32 + every DashPay + /// contact-receiving account, change returning to BIP44. Pass an explicit + /// `accountType` to restrict the payment to one family. + /// /// Kotlin parity: `ManagedPlatformWallet.buildSignedPayment` /// (`ManagedPlatformWallet.kt`). public func buildSignedPayment( recipients: [(address: String, amountDuffs: UInt64)], network: Network, - accountType: CoreTransactionBuilder.AccountType = .bip44, + accountType: CoreTransactionBuilder.AccountType = .allSpendable, accountIndex: UInt32 = 0 ) throws -> SignedCoreTransaction { guard !recipients.isEmpty else { From f15fe298cc19f5db8bb49b7c30ca4bc2d69288da Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 7 Aug 2026 22:10:53 +0700 Subject: [PATCH 4/5] fix(platform-wallet): classify set-selector misuse as a parameter error, correct two stale docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DashPay set selector handed to the per-account gap-limit API is a caller-argument error, but it returned WalletNotFound — rendering as "Wallet not found: ... names a set of accounts" and telling a host the wallet is missing. The FFI boundary already rejects the same class with its invalid-parameter code, so add a typed InvalidParameter variant mapped to that code and use it here. (AddressOperation, suggested in review, is the repo's pinned example of a variant that falls through to ErrorUnknown, which would report an internal failure for a bad argument.) Docs: the funding-sources paragraph was attached to single_preference, leaving funding_sources undocumented; and broadcast_payment_releasing_reservation still described an AccountTypePreference plus index after it moved to a slice of contributing AccountTypes. Co-Authored-By: Claude Opus 5 --- .../src/core_wallet/transaction_builder.rs | 7 ++++--- packages/rs-platform-wallet-ffi/src/error.rs | 7 +++++++ packages/rs-platform-wallet/src/error.rs | 10 ++++++++++ .../rs-platform-wallet/src/wallet/core/broadcast.rs | 8 +++++--- packages/rs-platform-wallet/src/wallet/core/wallet.rs | 5 ++++- 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index 55a6d181c6c..2cefb0888a0 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -70,9 +70,6 @@ pub enum CoreAccountTypeFFI { } impl CoreAccountTypeFFI { - /// The funding sources this selector pools, in funding order — handed to - /// [`CoreWallet::finalize_transaction`]'s multi-source API. Single-family - /// selectors keep their strict one-account semantics. /// The single account family this selector names, or `None` for the /// pooled [`AllSpendable`](Self::AllSpendable) — used by APIs that address /// exactly one account (gap limits, per-account UTXO listing), which must @@ -86,6 +83,10 @@ impl CoreAccountTypeFFI { } } + /// The funding sources this selector pools, in funding order — handed to + /// [`CoreWallet::finalize_transaction`]'s multi-source API, whose first + /// source supplies the change address. A single-family selector yields a + /// one-element list, which keeps that API's strict one-account semantics. pub(crate) fn funding_sources(self) -> &'static [AccountTypePreference] { match self { CoreAccountTypeFFI::BIP44 => &[AccountTypePreference::BIP44], diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 812df99f36d..c01da2a9fed 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -513,6 +513,13 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::MessageSigningMessageInvalid { .. } => { PlatformWalletFFIResultCode::ErrorInvalidParameter } + // A caller-argument rejection raised below the FFI boundary — the + // same class the boundary itself rejects with this code, so both + // sides agree instead of one reporting a not-found or an internal + // failure for a bad argument. + PlatformWalletError::InvalidParameter(..) => { + PlatformWalletFFIResultCode::ErrorInvalidParameter + } // A second producer of code 31 (the arm above is the first), // reached without any marker inspection at this layer: message // signing found no signable account for the address, or the signer diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index b33dc25713c..9d6ce8a0a4e 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -277,6 +277,16 @@ pub enum PlatformWalletError { #[error("Address operation failed: {0}")] AddressOperation(String), + /// A caller passed an argument this API cannot act on — as opposed to a + /// lookup that found nothing. Kept distinct from [`WalletNotFound`] so a + /// host is told to fix its input rather than that the wallet is missing; + /// FFI maps it to the existing invalid-parameter code, which is what the + /// FFI boundary already returns for the same class of rejection. + /// + /// [`WalletNotFound`]: Self::WalletNotFound + #[error("Invalid parameter: {0}")] + InvalidParameter(String), + #[error( "no selectable inputs: only funded addresses appear as destinations \ (funded_outputs={funded_outputs:?}, sub_min_count={sub_min_count}, \ diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index f0cf529cc41..6c53c5dba57 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -122,9 +122,11 @@ impl CoreWallet { /// under a new token is a real risk; the owner guard closes the /// `dashpay/platform#4185` release/re-reserve race. /// - /// `account_type`/`account_index` identify the funding account handed to the - /// builder when the transaction was finalized; `token` is the - /// [`ReservationToken`] that build stamped + /// `accounts` are the concrete accounts that contributed the transaction's + /// inputs (`SignedCoreTransaction::funding_accounts`) — a pooled send spans + /// several, and key-wallet reserves per account, so a rejection must + /// release on EVERY one of them. `token` is the [`ReservationToken`] that + /// build stamped across all of them /// (`SignedCoreTransaction::reservation_token`), `None` only when the build /// reserved nothing. pub(crate) async fn broadcast_payment_releasing_reservation( diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index a3d829b0920..29442935d4c 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -152,8 +152,11 @@ impl CoreWallet { // Gap limits are a per-account setting, so a set-naming DashPay // selector (identity-wide / all) has no single target here. + // A caller-argument error, not a lookup miss — the FFI boundary rejects + // the same class with its invalid-parameter code, so classify it the + // same way here rather than telling a host the wallet is missing. let concrete = account_type.account_type(account_index).ok_or_else(|| { - PlatformWalletError::WalletNotFound(format!( + PlatformWalletError::InvalidParameter(format!( "{account_type:?} names a set of accounts; set a gap limit per account" )) })?; From 0e1763c57267a52b99d22a4ebda12b35449deedb Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 7 Aug 2026 22:15:12 +0700 Subject: [PATCH 5/5] test(platform-wallet-ffi): pin the two new error-code mappings Neither new variant had direct coverage. The pooled one matters most: a pooled shortfall deliberately shares the single-account insufficient-funds code so hosts classify it identically, and splitting that later would silently reclassify the most common failure on the default send path. Co-Authored-By: Claude Opus 5 --- packages/rs-platform-wallet-ffi/src/error.rs | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index c01da2a9fed..99477d23677 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -865,6 +865,42 @@ mod tests { } } + /// A pooled shortfall is the same thing to a host as a single-account one — + /// "this wallet cannot cover the payment" — so it deliberately rides the + /// SAME code rather than making every host learn a second value. Pin that, + /// since splitting it later would silently reclassify the most common send + /// failure on the pooled (default) path. + #[test] + fn pooled_insufficient_funds_shares_the_single_account_code() { + let result: PlatformWalletFFIResult = PlatformWalletError::CorePooledInsufficientFunds { + sources: vec![ + AccountTypePreference::BIP44, + AccountTypePreference::BIP32, + AccountTypePreference::AllDashpayReceivingFunds, + ], + available: Some(1_000), + required: Some(2_000), + } + .into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorCoreInsufficientFunds + ); + } + + /// A caller-argument rejection raised BELOW the FFI boundary must reach the + /// host as the same parameter error the boundary itself returns — not as a + /// not-found, and not through the `ErrorUnknown` catch-all. + #[test] + fn invalid_parameter_maps_to_the_parameter_code() { + let result: PlatformWalletFFIResult = + PlatformWalletError::InvalidParameter("names a set of accounts".to_string()).into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + } + #[test] fn asset_lock_recovery_failures_map_to_stable_codes() { use dashcore::OutPoint;