Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same capture here.

The doc block starting at line 629 ("Fund the build from the inputs core_wallet_tx_builder_add_inputs_from_outpoints supplied, and nothing else" … "fails with a too-many-inputs error") belongs to core_wallet_tx_builder_use_only_added_inputs, which now sits at line 674 with only the boilerplate # Safety note.

This one leaks further than the Rust-side twin: cbindgen emits these into the generated C header the Swift SDK imports, so the public header will describe a balance getter in terms of batched-drain funding, while use_only_added_inputs — new in the base PR, and whose entire rationale lived in that block — ships with no explanation at all.

/// 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]
Expand Down
55 changes: 55 additions & 0 deletions packages/rs-platform-wallet/src/wallet/core/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,61 @@ pub(crate) fn resolve_source_accounts(
impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
/// Consume a configured builder, atomically fund and reserve its selected

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc comment got captured by the new function.

The insertion landed between finalize_transaction's doc comment and its signature, so "Consume a configured builder, atomically fund and reserve its selected inputs, then sign without holding the wallet-manager lock." is now the summary line of pooled_spendable_balance — a read-only getter that reserves and signs nothing — running straight on into the real description with no separating ///.

finalize_transaction (line 357) is left with no doc at all.

/// 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a test — the fixtures are already there.

mod tests in this file (line 695) has what's needed:

  • funded_wallet_manager_dual_standard(&[700_000], &[700_000]) — already used by pooled_send_spans_families_and_abandon_releases_all; asserting pooled_spendable_balance(&SEND_FUNDING_SOURCES, 0) == 1_400_000 is about three lines.
  • funded_wallet_manager_with_contact covers the DashPay leg.

Nothing currently pins this function to finalize_transaction's account set, which is the one coupling the PR exists to enforce. Both divergences I flagged would be caught by that test.

&self,
sources: &[AccountTypePreference],
source_index: u32,
) -> Result<u64, PlatformWalletError> {
let mut manager = self.wallet_manager.write().await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Write lock for a pure read.

Nothing here mutates. Read-only equivalents exist and are what the rest of the package uses:

  • WalletManager::get_wallet_and_info (key-wallet-manager/src/accessors.rs:29)
  • ManagedAccountCollection::funds_account (key-wallet/src/managed_account/managed_account_collection.rs:581)

so this can be .read().await + get_wallet_and_info + funds_account, and the _wallet binding drops out.

It matters because of how this is meant to be called — gating amount entry means potentially one call per keystroke, each taking the exclusive manager lock against concurrent finalizers, broadcast reconciliation and SPV sync writers. Every other read-only path in the package already takes the read lock: sign_message.rs:149, broadcast.rs:136, wallet.rs:372/400, and transaction.rs:650 in this same file.

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<AccountType> = HashSet::new();
let mut total: u64 = 0;
for &preference in sources {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single-source selectors silently return 0 instead of the strict not-found error.

finalize_transaction_with_options sets strict = sources.len() == 1 and errors with WalletNotFound when the named account is missing — pinned by single_source_missing_account_still_errors (line 866). This loop applies the skip-missing rule unconditionally.

CoreAccountTypeFFI::funding_sources() returns a one-element list for BIP44, BIP32 and CoinJoin, so pooledSpendableBalance(accountType: .bip32) on a wallet with no BIP32 account returns Ok(0). The host renders "insufficient funds"; the matching finalize call would have said "no such account". Two different answers to the same selector.

That also makes the doc's "the same accounts finalize_transaction funds from" untrue for every single-family selector — worth either taking a strict flag or narrowing the doc.

for at in resolve_source_accounts(&info.core_wallet.accounts, preference, source_index)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop is a second copy of the one in finalize_transaction_with_options (lines ~434-447).

The PR's argument is that the host's hand-copy of the pooling rule drifted, so hosts should read it from the source of truth instead. But the new source of truth is itself a hand-copy of the funding loop — and it has already drifted twice before merge (the two comments above).

Extracting something like

fn resolved_funding_accounts(
    accounts: &ManagedAccountCollection,
    wallet: &Wallet,
    sources: &[AccountTypePreference],
    source_index: u32,
    strict: bool,
) -> Result<Vec<AccountType>, PlatformWalletError>

and driving both call sites from it makes the two impossible to desynchronise, and drops the duplicated dedup HashSet as a side effect. That seems like the depth the fix actually wants to sit at.

{
if !seen.insert(at) {
continue;
}
let Some(managed) = info.core_wallet.accounts.funds_account_mut(&at) else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diverges from finalize here: the account_of_type half of the check is missing.

finalize_transaction_with_options requires both to resolve before it funds an account:

let (Some(account), Some(managed)) = (
    wallet.accounts.account_of_type(at),
    info.core_wallet.accounts.funds_account_mut(&at),
) else { ... continue; };

This counts an account when only funds_account_mut resolves. An AccountType present in info.core_wallet.accounts but absent from wallet.accounts is added to the total and then skipped by the build — an over-report of exactly the shape the PR is fixing.

The discarded _wallet binding on line 331 is where the other half went.

continue;
};
total += managed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fee isn't subtracted, so this ceiling is still unspendable as an amount.

The doc above says hosts should gate amount entry on this, but the value returned is the gross sum of spendable UTXOs. A build needs amount + fee.

select_coins_with_size passes the total_available < target_amount check with the full sum, and then accumulate_coins_with_size can't cover target + fee — so a user tapping a max/"send all" button wired to this and entering the returned value verbatim gets exactly the CorePooledInsufficientFunds this PR is removing, just relocated from the CoinJoin edge to the max-amount edge.

With the 500-UTXO sweep landing alongside (#4548), the input count and therefore the fee can be well above dust, so this isn't a rounding concern.

Either subtract an estimated fee here, or state in the doc that the figure is pre-fee and hosts must reserve headroom — but given the failure mode this is meant to prevent, I'd rather it be handled here than left as a second thing for hosts to mirror.

.spendable_utxos(height)
.iter()
.map(|utxo| utxo.value())
.sum::<u64>();
}
}
Ok(total)
}

pub async fn finalize_transaction<S: TransactionSigner + ?Sized + Sync>(
&self,
builder: TransactionBuilder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading