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)