From 28044ee26d8e4d3e3a0257c98a85e1a137d7c4e7 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:02:05 +0300 Subject: [PATCH 1/2] feat(platform-wallet): report the balance a pooled build can actually spend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `core_wallet_get_balance` sums every funding account the wallet has — CoinJoin included — and never consults a reservation set. A host gating its amount entry on it therefore offers money the build then refuses, and the shortfall surfaces as CorePooledInsufficientFunds only after the user has committed to an amount. Support ticket 32081 is the shape of it: a wallet reading 94 DASH, of which 0.0054 was actually spendable, everything else on the CoinJoin account the send pool excludes by design. The same mismatch produces the asset-lock shortfall on the Transparent to Shielded path. `pooled_spendable_balance` answers with the accounts `finalize_transaction` funds from, resolved through the same `resolve_source_accounts` and the same source list, counting only UTXOs coin selection accepts. Hosts read it instead of mirroring the pooling rule themselves — the mirror is what drifted here. Reservations are not subtracted: key-wallet keeps each account's ReservationSet private, so reading it needs an accessor there and a pin bump. Documented at every layer. That part is transient — a reservation is released when its spend is processed, on a definitive rejection, at the TTL, or on restart — while the account-set difference is permanent and was the whole of the reported shortfall. --- .../src/core_wallet/transaction_builder.rs | 33 +++++++++++ .../src/wallet/core/transaction.rs | 55 +++++++++++++++++++ .../CoreWallet/ManagedCoreWallet.swift | 21 +++++++ 3 files changed, 109 insertions(+) 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 79d3c00e96d..a4ba6ffa4db 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 @@ -635,6 +635,39 @@ pub unsafe extern "C" fn core_wallet_tx_builder_set_fee_rate( /// each stay under the standard-transaction input limit needs this, or every /// batch sees the whole account and fails with a too-many-inputs error. /// +/// The balance a build funded by `account_type` could actually select from — the +/// same accounts `core_wallet_tx_builder_finalize` would fund from, counting +/// only UTXOs coin selection accepts. +/// +/// Gate amount entry on this rather than on `core_wallet_get_balance`, which +/// sums every funding account the wallet has — CoinJoin included — and so +/// reports money a build then refuses. +/// +/// Reservations are not subtracted; see `CoreWallet::pooled_spendable_balance`. +/// +/// # Safety +/// `out_balance` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_pooled_spendable_balance( + wallet: Handle, + account_type: CoreAccountTypeFFI, + account_index: u32, + out_balance: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_balance); + *out_balance = 0; + + let wallet = unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet, |w| w.clone())); + let balance = unwrap_result_or_return!(runtime().block_on( + wallet + .core() + .pooled_spendable_balance(account_type.funding_sources(), account_index) + )); + + *out_balance = balance; + PlatformWalletFFIResult::ok() +} + /// # Safety /// `builder` must be a valid, non-destroyed pointer. #[no_mangle] diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 183dcb7fbbb..943da849334 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -299,6 +299,61 @@ pub(crate) fn resolve_source_accounts( impl CoreWallet { /// Consume a configured builder, atomically fund and reserve its selected /// inputs, then sign without holding the wallet-manager lock. + /// The balance a pooled build could actually select from — the same + /// accounts [`Self::finalize_transaction`] funds from, counting only UTXOs + /// coin selection would accept. + /// + /// Hosts gate their amount entry on this. The wallet-level balance is a + /// strict superset: it sums every funding account, CoinJoin included, and + /// never consults a reservation set, so gating on it offers money the build + /// then refuses — the shortfall surfacing as + /// [`CorePooledInsufficientFunds`](PlatformWalletError::CorePooledInsufficientFunds) + /// after the user has already committed to an amount. + /// + /// Missing sources are skipped, as in a pooled build: a wallet without a + /// BIP32 account or without DashPay contacts still has a spendable balance. + /// + /// Reservations are NOT subtracted: key-wallet keeps each account's + /// `ReservationSet` private, so reading it needs an accessor there and a pin + /// bump. The figure is therefore optimistic by whatever another in-flight + /// build currently holds — transient by construction, since a reservation is + /// released when its spend is processed, on a definitive broadcast + /// rejection, at the TTL, or on restart. The account-set mismatch this fixes + /// is permanent, and was the whole of the shortfall in the report that + /// prompted it (support ticket 32081: 0.0054 DASH offered as spendable + /// against a 94 DASH balance, all of it CoinJoin). + pub async fn pooled_spendable_balance( + &self, + sources: &[AccountTypePreference], + source_index: u32, + ) -> Result { + 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 height = info.core_wallet.last_processed_height(); + + let mut seen: HashSet = HashSet::new(); + let mut total: u64 = 0; + for &preference in sources { + for at in resolve_source_accounts(&info.core_wallet.accounts, preference, source_index) + { + if !seen.insert(at) { + continue; + } + let Some(managed) = info.core_wallet.accounts.funds_account_mut(&at) else { + continue; + }; + total += managed + .spendable_utxos(height) + .iter() + .map(|utxo| utxo.value()) + .sum::(); + } + } + Ok(total) + } + pub async fn finalize_transaction( &self, builder: TransactionBuilder, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift index 8ce56359353..d01496a8fb1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift @@ -89,6 +89,27 @@ public class ManagedCoreWallet { ) } + /// The balance a build funded by `accountType` could actually select from — + /// the same accounts `finalizeAtomic` funds from, counting only UTXOs coin + /// selection accepts. + /// + /// Gate amount entry on this, not on ``balance()``: that sums every funding + /// account the wallet has, CoinJoin included, so a wallet holding mixed + /// coins is offered money the build then refuses. + /// + /// Reservations are not subtracted — an in-flight build's inputs still + /// count here. That is transient; the account-set difference is not. + public func pooledSpendableBalance( + accountType: CoreTransactionBuilder.AccountType = .allSpendable, + accountIndex: UInt32 = 0 + ) throws -> UInt64 { + var balance: UInt64 = 0 + try core_wallet_pooled_spendable_balance( + handle, accountType.ffi, accountIndex, &balance + ).check() + return balance + } + /// Get the network this wallet operates on. public func network() throws -> Network { var ffiNetwork = FFINetwork(0) From 96c8be13373204adb02eefafc28bebfd83bf1995 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:17:12 +0300 Subject: [PATCH 2/2] refactor(platform-wallet): drive both funding paths from one account resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the new balance call had already drifted from the funding loop it was meant to be the truth for — the point of the PR, reproduced inside it. `resolved_funding_accounts` now names the accounts, and both `finalize_transaction_with_options` and `pooled_spendable_balance` are driven from it. Three divergences go with it: - the balance counted an account that resolved only on the managed side, while funding requires both halves and skips otherwise, so it over-reported exactly the shape this PR removes; - single-source selectors returned Ok(0) where funding errors WalletNotFound, giving two answers to the same selector — the strict rule, including the empty SET selector case, now lives in the resolver; - the dedup set existed twice. The balance also takes the read lock rather than the write lock: nothing here mutates, and gating amount entry means a call per keystroke against concurrent finalizers and sync writers. The fee is documented rather than subtracted. Doing it here means re-declaring key-wallet's input and output sizes in this crate, which is the duplication the call exists to remove; the estimate belongs beside FeeRate and MAX_STANDARD_TX_INPUTS. --- .../src/wallet/core/transaction.rs | 136 ++++++++++++------ 1 file changed, 96 insertions(+), 40 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 943da849334..03f068c19ff 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use dashcore::{Address, OutPoint, Transaction}; use key_wallet::account::AccountType; +use key_wallet::managed_account::managed_account_collection::ManagedAccountCollection; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionError; use key_wallet::wallet::managed_wallet_info::transaction_builder::{ @@ -17,6 +18,7 @@ use key_wallet::wallet::managed_wallet_info::transaction_builder::{ }; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::Wallet; use key_wallet::{DerivationPath, ReservationToken, Utxo}; use super::{CoreWallet, WalletGeneration}; @@ -267,6 +269,58 @@ pub const ASSET_LOCK_FUNDING_SOURCES: [AccountTypePreference; 3] = SEND_FUNDING_ /// 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. +/// The accounts a pooled build will actually fund from, in funding order and +/// deduplicated: those `resolve_source_accounts` names AND that resolve on both +/// halves — the keys side (`wallet.accounts`) and the managed side +/// (`info.core_wallet.accounts`). An account present in only one is skipped, +/// because funding needs both. +/// +/// `strict` reproduces the single-source contract: naming ONE account is an +/// explicit request for it, so a miss is an error rather than a silent skip. A +/// pooled call skips instead — a wallet without a BIP32 account or without +/// DashPay contacts still funds from what it has. +/// +/// Shared by `finalize_transaction_with_options` and +/// `pooled_spendable_balance` so the set one reports can never drift from the +/// set the other funds. +pub(crate) fn resolved_funding_accounts( + accounts: &ManagedAccountCollection, + wallet: &Wallet, + sources: &[AccountTypePreference], + source_index: u32, + strict: bool, +) -> Result, PlatformWalletError> { + let mut seen: HashSet = HashSet::new(); + let mut resolved: Vec = Vec::new(); + for &preference in sources { + for at in resolve_source_accounts(accounts, preference, source_index) { + if !seen.insert(at) { + continue; + } + if wallet.accounts.account_of_type(at).is_none() + || accounts.funds_account(&at).is_none() + { + if strict { + return Err(PlatformWalletError::WalletNotFound(format!( + "wallet account {preference:?} #{source_index} not found" + ))); + } + continue; + } + resolved.push(at); + } + } + // A strict SET selector (a DashPay preference naming zero accounts) is a + // miss too: the caller asked for exactly those funds. + if strict && resolved.is_empty() { + return Err(PlatformWalletError::WalletNotFound(format!( + "wallet account {:?} #{source_index} not found", + sources.first() + ))); + } + Ok(resolved) +} + pub(crate) fn resolve_source_accounts( accounts: &key_wallet::account::ManagedAccountCollection, preference: AccountTypePreference, @@ -313,6 +367,18 @@ impl CoreWallet { /// Missing sources are skipped, as in a pooled build: a wallet without a /// BIP32 account or without DashPay contacts still has a spendable balance. /// + /// **Gross, not net of fee.** This is the sum a build may draw on; a build + /// needs `amount + fee`, so a host offering this verbatim as a max amount + /// moves the shortfall from the CoinJoin edge to the max-amount edge rather + /// than removing it. Hosts must keep reserving fee headroom, as they did + /// against the wallet-wide figure this replaces — the fee is unchanged by + /// this call, only the account set is. + /// + /// Subtracting it here would mean re-declaring key-wallet's per-input and + /// per-output sizes in this crate, which is the same duplication the call + /// exists to remove. The estimate belongs beside `MAX_STANDARD_TX_INPUTS` + /// and `FeeRate`, in key-wallet. + /// /// Reservations are NOT subtracted: key-wallet keeps each account's /// `ReservationSet` private, so reading it needs an accessor there and a pin /// bump. The figure is therefore optimistic by whatever another in-flight @@ -327,29 +393,31 @@ impl CoreWallet { sources: &[AccountTypePreference], source_index: u32, ) -> Result { - let mut manager = self.wallet_manager.write().await; - let (_wallet, info) = manager - .get_wallet_and_info_mut(&self.wallet_id) + let manager = self.wallet_manager.read().await; + let (wallet, info) = manager + .get_wallet_and_info(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound("wallet not found".into()))?; let height = info.core_wallet.last_processed_height(); - let mut seen: HashSet = HashSet::new(); + // Same resolver, same strictness rule, as the funding path. + let resolved = resolved_funding_accounts( + &info.core_wallet.accounts, + wallet, + sources, + source_index, + sources.len() == 1, + )?; + let mut total: u64 = 0; - for &preference in sources { - for at in resolve_source_accounts(&info.core_wallet.accounts, preference, source_index) - { - if !seen.insert(at) { - continue; - } - let Some(managed) = info.core_wallet.accounts.funds_account_mut(&at) else { - continue; - }; - total += managed - .spendable_utxos(height) - .iter() - .map(|utxo| utxo.value()) - .sum::(); - } + for at in resolved { + let Some(managed) = info.core_wallet.accounts.funds_account(&at) else { + continue; + }; + total += managed + .spendable_utxos(height) + .iter() + .map(|utxo| utxo.value()) + .sum::(); } Ok(total) } @@ -426,24 +494,20 @@ impl CoreWallet { // 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 !offered_seen.insert(at) { - continue; - } + let resolved = resolved_funding_accounts( + &info.core_wallet.accounts, + wallet, + sources, + source_index, + strict, + )?; + { + for at in resolved { 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() { @@ -458,14 +522,6 @@ impl CoreWallet { }; 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 && offered_accounts.is_empty() { - return Err(PlatformWalletError::WalletNotFound(format!( - "wallet account {preference:?} #{source_index} not found" - ))); - } } if offered_accounts.is_empty() { return Err(PlatformWalletError::WalletNotFound(format!(