Skip to content

Commit 5313086

Browse files
ZocoLiniclaude
andauthored
fix(key-wallet): let CoinSelector spend mempool UTXOs (#748)
* test(dash-spv): cover spending mempool change and unconfirmed incoming UTXOs Adds two regression tests in `tests/dashd_sync/tests_transaction_builder.rs` that drive `ManagedWalletInfo::build_and_sign_transaction` end-to-end against a real dashd regtest node, broadcasting via the SPV client. Both tests use a fresh-mnemonic wallet (EMPTY/SECONDARY) so the SPV side has no preexisting confirmed UTXOs from the test chain that would mask the bug. - test_spend_unconfirmed_change_balance: confirms the wallet reports trusted mempool change as spendable, then fails to actually spend it through coin selection (the filter ignores `is_trusted`). - test_spend_unconfirmed_incoming_balance: confirms the wallet reports unconfirmed incoming as spendable, then fails to spend it. Both tests are red against the current coin selector and document the user- reported symptom ("balance shows up but cannot be spent"). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(key-wallet): defer CoinSelector candidate filter to Utxo::is_spendable `CoinSelector` carried its own confirmation policy (`min_confirmations`, `include_unconfirmed`) and filtered candidates with `(include_unconfirmed || is_confirmed || is_instantlocked)` plus a `current_height - utxo.height >= min_confirmations` depth check. Two consequences: 1. `is_trusted` was missing from the OR — mempool change from a tx the wallet itself authored was rejected, even though the balance UI shows it as spendable. Users hit this as `CoinSelection(NoUtxosAvailable)` immediately after sending. 2. The depth check used `>= 1`, which silently rejected a UTXO mined into the wallet's tip block (depth 0, but 1 confirmation in the usual sense), and gated all unconfirmed incoming through the same path. The `Utxo` itself already has `is_spendable(current_height)` — which checks not-locked and coinbase maturity — and the wallet decides what to put in the UTXO map (with `is_confirmed`, `is_instantlocked`, `is_trusted` flags reflecting its policy). The coin selector having a *second*, divergent policy on top is what produced the bug. Drop the duplicated machinery: remove `min_confirmations`, `include_unconfirmed`, and the per-call helpers. Both filter sites now defer to `Utxo::is_spendable`, so coin selection accepts every UTXO the wallet tracks as spendable — confirmed, IS-locked, trusted self-send change, and incoming mempool alike. Tightens `tests_transaction_builder.rs` accordingly: the workaround that mined an extra "bump" tx so `last_processed_height` advanced past the funding UTXO is no longer needed. All 482 key-wallet unit tests and 26 dashd_sync integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(dash-spv): integrations tests cleanup --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 34f574d commit 5313086

2 files changed

Lines changed: 162 additions & 36 deletions

File tree

dash-spv/tests/dashd_sync/tests_transaction.rs

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
use dash_spv::sync::ProgressPercentage;
2-
use dashcore::Amount;
3-
4-
use super::helpers::wait_for_sync;
5-
use super::setup::TestContext;
2+
use dashcore::{Address, Amount, Network};
3+
use std::sync::Arc;
4+
use std::time::Duration;
5+
use tokio::sync::RwLock;
6+
7+
use super::helpers::{
8+
wait_for_mempool_tx, wait_for_sync, wait_for_wallet_synced, EMPTY_MNEMONIC, SECONDARY_MNEMONIC,
9+
};
10+
use super::setup::{create_and_start_client, create_test_wallet, TestContext};
611
use dash_spv::test_utils::TestChain;
12+
use dashcore::address::NetworkUnchecked;
13+
use key_wallet::account::ManagedAccountTrait;
14+
use key_wallet::wallet::managed_wallet_info::transaction_builder::{
15+
BuilderError, TransactionBuilder,
16+
};
17+
use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface;
18+
use key_wallet::wallet::ManagedWalletInfo;
19+
use key_wallet::ManagedAccountType;
20+
use key_wallet_manager::{WalletId, WalletManager};
721

822
/// Verify incremental sync works by generating blocks after initial sync.
923
///
@@ -230,3 +244,144 @@ async fn test_multiple_transactions_across_blocks() {
230244
fees_paid
231245
);
232246
}
247+
248+
const MEMPOOL_TIMEOUT: Duration = Duration::from_secs(30);
249+
250+
async fn reserve_first_address(mnemonic: &str) -> Address {
251+
let (temp_mgr, temp_id) = create_test_wallet(mnemonic, Network::Regtest);
252+
253+
let reader = temp_mgr.read().await;
254+
let info = reader.get_wallet_info(&temp_id).expect("wallet info");
255+
let account = info.accounts().standard_bip44_accounts.get(&0).expect("BIP44 account 0");
256+
257+
let ManagedAccountType::Standard {
258+
external_addresses,
259+
..
260+
} = &account.managed_account_type()
261+
else {
262+
panic!("not a Standard account");
263+
};
264+
265+
external_addresses.unused_addresses().into_iter().next().expect("unused address")
266+
}
267+
268+
async fn build_and_sign(
269+
wallet: &Arc<RwLock<WalletManager<ManagedWalletInfo>>>,
270+
wallet_id: &WalletId,
271+
destination: &Address,
272+
amount: u64,
273+
) -> Result<(dashcore::Transaction, u64), BuilderError> {
274+
let dest_unchecked: Address<NetworkUnchecked> =
275+
destination.to_string().parse().expect("destination address");
276+
277+
let mut wallet_lock = wallet.write().await;
278+
let (w, info) = wallet_lock.get_wallet_and_info_mut(wallet_id).expect("wallet present");
279+
280+
let height = info.last_processed_height();
281+
let network = w.network;
282+
let account = w.get_bip44_account(0).expect("account 0").clone();
283+
let funds_account = info.accounts.standard_bip44_accounts.get_mut(&0).expect("account 0");
284+
let dest = dest_unchecked.require_network(network).expect("destination network");
285+
286+
TransactionBuilder::new()
287+
.set_current_height(height)
288+
.set_funding(funds_account, &account)
289+
.add_output(&dest, amount)
290+
.build_signed(w, |a| funds_account.address_derivation_path(&a))
291+
.await
292+
}
293+
294+
/// Build, sign and broadcast a tx via `TransactionBuilder`, then re-spend
295+
/// the resulting mempool change UTXO before its parent confirms.
296+
#[tokio::test]
297+
async fn test_spend_change_balance() {
298+
let Some(ctx) = TestContext::new(TestChain::Minimal).await else {
299+
return;
300+
};
301+
if !ctx.dashd.supports_mining {
302+
eprintln!("Skipping test (dashd RPC miner not available)");
303+
return;
304+
}
305+
306+
let (wallet, wallet_id) = create_test_wallet(EMPTY_MNEMONIC, Network::Regtest);
307+
let mut client_handle = create_and_start_client(&ctx.client_config, Arc::clone(&wallet)).await;
308+
wait_for_sync(&mut client_handle.progress_receiver, ctx.dashd.initial_height).await;
309+
310+
let receive_address = reserve_first_address(EMPTY_MNEMONIC).await;
311+
let funding_amount = Amount::from_sat(500_000_000);
312+
ctx.dashd.node.send_to_address(&receive_address, funding_amount);
313+
314+
let miner_address = ctx.dashd.node.get_new_address_from_wallet("default");
315+
ctx.dashd.node.generate_blocks(1, &miner_address);
316+
let funded_height = ctx.dashd.initial_height + 1;
317+
wait_for_sync(&mut client_handle.progress_receiver, funded_height).await;
318+
wait_for_wallet_synced(&wallet, &wallet_id, funded_height).await;
319+
320+
let dest_a = Address::dummy(Network::Regtest, 1);
321+
let (tx_a, _) =
322+
build_and_sign(&wallet, &wallet_id, &dest_a, 100_000_000).await.expect("build tx_a");
323+
324+
client_handle.client.broadcast_transaction(&tx_a).await.expect("broadcast tx_a");
325+
wait_for_mempool_tx(&mut client_handle.wallet_event_receiver, MEMPOOL_TIMEOUT)
326+
.await
327+
.expect("detect tx_a");
328+
329+
// The wallet's only UTXO now is the mempool change from tx_a, so a
330+
// successful build proves coin selection used it.
331+
let dest_b = Address::dummy(Network::Regtest, 2);
332+
let (tx_b, _) = build_and_sign(&wallet, &wallet_id, &dest_b, 50_000_000)
333+
.await
334+
.expect("spend mempool change");
335+
assert!(
336+
tx_b.input.iter().any(|i| i.previous_output.txid == tx_a.txid()),
337+
"tx_b must spend tx_a's mempool change UTXO",
338+
);
339+
340+
client_handle.client.broadcast_transaction(&tx_b).await.expect("broadcast tx_b");
341+
wait_for_mempool_tx(&mut client_handle.wallet_event_receiver, MEMPOOL_TIMEOUT)
342+
.await
343+
.expect("detect tx_b");
344+
345+
client_handle.stop().await;
346+
}
347+
348+
/// Spend an incoming mempool UTXO (we own the output, none of the inputs)
349+
/// before it confirms.
350+
#[tokio::test]
351+
async fn test_spend_incoming_balance() {
352+
let Some(ctx) = TestContext::new(TestChain::Minimal).await else {
353+
return;
354+
};
355+
if !ctx.dashd.supports_mining {
356+
eprintln!("Skipping test (dashd RPC miner not available)");
357+
return;
358+
}
359+
360+
let (wallet, wallet_id) = create_test_wallet(SECONDARY_MNEMONIC, Network::Regtest);
361+
let mut client_handle = create_and_start_client(&ctx.client_config, Arc::clone(&wallet)).await;
362+
wait_for_sync(&mut client_handle.progress_receiver, ctx.dashd.initial_height).await;
363+
364+
let receive_address = reserve_first_address(SECONDARY_MNEMONIC).await;
365+
let incoming_amount = Amount::from_sat(300_000_000);
366+
let incoming_txid = ctx.dashd.node.send_to_address(&receive_address, incoming_amount);
367+
368+
wait_for_mempool_tx(&mut client_handle.wallet_event_receiver, MEMPOOL_TIMEOUT)
369+
.await
370+
.expect("detect incoming");
371+
372+
let dest = Address::dummy(Network::Regtest, 3);
373+
let (tx, _) = build_and_sign(&wallet, &wallet_id, &dest, 150_000_000)
374+
.await
375+
.expect("spend unconfirmed incoming");
376+
assert!(
377+
tx.input.iter().any(|i| i.previous_output.txid == incoming_txid),
378+
"spend must reference the unconfirmed incoming txid",
379+
);
380+
381+
client_handle.client.broadcast_transaction(&tx).await.expect("broadcast spend");
382+
wait_for_mempool_tx(&mut client_handle.wallet_event_receiver, MEMPOOL_TIMEOUT)
383+
.await
384+
.expect("detect spend");
385+
386+
client_handle.stop().await;
387+
}

key-wallet/src/wallet/managed_wallet_info/coin_selection.rs

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -79,34 +79,17 @@ pub struct SelectionResult {
7979
/// - High-frequency receivers: **SmallestFirstTill(10)** (balanced approach)
8080
pub struct CoinSelector {
8181
strategy: SelectionStrategy,
82-
min_confirmations: u32,
83-
include_unconfirmed: bool,
8482
dust_threshold: u64,
8583
}
8684

8785
impl CoinSelector {
88-
/// Create a new coin selector
8986
pub fn new(strategy: SelectionStrategy) -> Self {
9087
Self {
9188
strategy,
92-
min_confirmations: 1,
93-
include_unconfirmed: false,
9489
dust_threshold: 546, // Standard dust threshold
9590
}
9691
}
9792

98-
/// Set minimum confirmations required
99-
pub fn with_min_confirmations(mut self, confirmations: u32) -> Self {
100-
self.min_confirmations = confirmations;
101-
self
102-
}
103-
104-
/// Include unconfirmed UTXOs
105-
pub fn include_unconfirmed(mut self) -> Self {
106-
self.include_unconfirmed = true;
107-
self
108-
}
109-
11093
/// Set dust threshold
11194
pub fn with_dust_threshold(mut self, threshold: u64) -> Self {
11295
self.dust_threshold = threshold;
@@ -159,15 +142,8 @@ impl CoinSelector {
159142
| SelectionStrategy::BranchAndBound
160143
| SelectionStrategy::OptimalConsolidation => {
161144
// These strategies need all UTXOs to sort/analyze
162-
let mut available: Vec<&'a Utxo> = utxos
163-
.into_iter()
164-
.filter(|u| {
165-
u.is_spendable(current_height)
166-
&& (self.include_unconfirmed || u.is_confirmed || u.is_instantlocked)
167-
&& (current_height.saturating_sub(u.height) >= self.min_confirmations
168-
|| u.height == 0)
169-
})
170-
.collect();
145+
let mut available: Vec<&'a Utxo> =
146+
utxos.into_iter().filter(|u| u.is_spendable(current_height)).collect();
171147

172148
if available.is_empty() {
173149
return Err(SelectionError::NoUtxosAvailable);
@@ -261,12 +237,7 @@ impl CoinSelector {
261237
}
262238
SelectionStrategy::Random => {
263239
// Random can work with iterators directly
264-
let filtered = utxos.into_iter().filter(|u| {
265-
u.is_spendable(current_height)
266-
&& (self.include_unconfirmed || u.is_confirmed || u.is_instantlocked)
267-
&& (current_height.saturating_sub(u.height) >= self.min_confirmations
268-
|| u.height == 0)
269-
});
240+
let filtered = utxos.into_iter().filter(|u| u.is_spendable(current_height));
270241

271242
// For Random (currently just uses accumulate as-is)
272243
// TODO: Implement proper random selection for privacy

0 commit comments

Comments
 (0)