bitcoindevkit / bitcoindevkit/bdk

Unconfirmed spend with an invalid witness removes a confirmed UTXO from the balance

Open
#2,301 0 comments 0 reactions 0 assignees View on GitHub
bug
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.