bitcoindevkit / bitcoindevkit/bdk
Unconfirmed spend with an invalid witness removes a confirmed UTXO from the balance
- Dominant language
- Rust
- Stars
- 1.1k
- Forks
- 483
- Avg merge
- 20d 3h
- Merged PRs (30d)
- 3
Description
**Describe the bug**
`CanonicalView` marks an outpoint as spent (`CanonicalTxOut::spent_by`) as soon as any canonical transaction has it as an input, and `filter_unspent_outpoints` and `balance` then exclude it. An unconfirmed transaction becomes canonical with just a `seen_at` and no conflicting anchored or newer transaction; nothing about the transaction itself is checked.
The Electrum and Esplora clients insert every unconfirmed transaction reported for a tracked script exactly this way (`populate_with_spks` in `crates/electrum/src/bdk_electrum_client.rs:333-343`, `insert_anchor_or_seen_at_from_status` in `crates/esplora/src/lib.rs`). A chain source that returns a transaction spending a wallet UTXO with an empty or otherwise unsatisfiable witness therefore removes that UTXO from the unspent set and the confirmed balance, and the output is unavailable for coin selection for as long as the source keeps reporting the transaction. Eviction via `expected_txids` only happens once a sync no longer sees it.
This issue was found by AI.
**To Reproduce**
Add `crates/chain/tests/test_unvalidated_spend.rs` and run `cargo test -p bdk_chain --test test_unvalidated_spend`:
```rust
use bdk_chain::{local_chain::LocalChain, ConfirmationBlockTime, TxGraph};
use bdk_testenv::{hash, utils::new_tx};
use bitcoin::{hashes::Hash, Amount, OutPoint, ScriptBuf, TxIn, TxOut, WPubkeyHash, Witness};
#[test]
fn unconfirmed_spend_with_empty_witness_removes_utxo() {
let chain = LocalChain::from_blocks((0..=10).map(|h| (h, hash!("block"))).collect()).unwrap();
let mut graph = TxGraph::::default();
// Confirmed 50_000 sat output to a P2WPKH script tracked by the wallet.
let funding = bitcoin::Transaction {
output: vec![TxOut {
value: Amount::from_sat(50_000),
script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([1; 20])),
}],
..new_tx(0)
};
let utxo = OutPoint::new(funding.compute_txid(), 0);
let _ = graph.insert_tx(funding.clone());
let _ = graph.insert_anchor(
funding.compute_txid(),
ConfirmationBlockTime { block_id: chain.get(5).unwrap().block_id(), confirmation_time: 0 },
);
// Unconfirmed spend of that output with an empty witness, which can never satisfy P2WPKH.
// This is how the Electrum/Esplora clients insert an unconfirmed history entry.
let bogus_spend = bitcoin::Transaction {
input: vec![TxIn { previous_output: utxo, witness: Witness::new(), ..Default::default() }],
output: vec![TxOut { value: Amount::from_sat(49_000), script_pubkey: ScriptBuf::new() }],
..new_tx(1)
};
let _ = graph.insert_tx(bogus_spend.clone());
let _ = graph.insert_seen_at(bogus_spend.compute_txid(), 1_000);
let view = chain.canonical_view(&graph, chain.tip().block_id(), Default::default());
let balance = view.balance([((), utxo)], |_, _| true, 1);
assert_eq!(
balance.confirmed,
Amount::from_sat(50_000),
"utxo spent by {:?}",
view.txout(utxo).unwrap().spent_by.map(|(_, txid)| txid),
);
}
```
The assertion fails with `utxo spent by Some(1a821da4…)`; `balance.confirmed` is `0 SAT` instead of `50000 SAT`.
**Expected behavior**
An unconfirmed transaction reported by the chain source that cannot be valid should not remove a confirmed output from the unspent set and balance.
Contributor guide
Research direction
Start with the failing regression in crates/chain/tests/test_unvalidated_spend.rs, then trace CanonicalView, filter_unspent_outpoints, and balance to see how spent_by is assigned. Review the unconfirmed transaction insertion paths in crates/electrum/src/bdk_electrum_client.rs:333-343 and crates/esplora/src/lib.rs. Done means an invalid unconfirmed spend reported by a chain source no longer removes the confirmed UTXO from the unspent set or confirmed balance.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 65/100