Skip to content

Commit 11c1eb3

Browse files
committed
fix(chain): redefine relevant txs in IndexedTxGraph
A transaction's relevancy was originally only determined by the spks referenced by the tx's inputs and outputs. A new rule is added where if a tx shares inputs with anything contained in TxGraph, then it should also be considered relevant. This fixes a potential double spending problem.
1 parent bcff89d commit 11c1eb3

File tree

3 files changed

+146
-7
lines changed

3 files changed

+146
-7
lines changed

crates/chain/src/indexed_tx_graph.rs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,12 @@ where
178178

179179
let mut tx_graph = tx_graph::ChangeSet::default();
180180
for (tx, anchors) in txs {
181-
if self.index.is_tx_relevant(&tx) {
181+
if self.index.is_tx_relevant(&tx)
182+
|| self
183+
.graph
184+
.direct_conflicts(&tx)
185+
.any(|(_, txid)| self.graph.get_tx(txid).is_some())
186+
{
182187
let txid = tx.compute_txid();
183188
tx_graph.merge(self.graph.insert_tx(tx.clone()));
184189
for anchor in anchors {
@@ -218,11 +223,18 @@ where
218223
indexer.merge(self.index.index_tx(tx));
219224
}
220225

221-
let graph = self.graph.batch_insert_unconfirmed(
222-
txs.into_iter()
223-
.filter(|(tx, _)| self.index.is_tx_relevant(tx))
224-
.map(|(tx, seen_at)| (tx.clone(), seen_at)),
225-
);
226+
let mut relevant_txs = Vec::new();
227+
for (tx, seen_at) in txs.into_iter() {
228+
if self.index.is_tx_relevant(&tx)
229+
|| self
230+
.graph
231+
.direct_conflicts(&tx)
232+
.any(|(_, txid)| self.graph.get_tx(txid).is_some())
233+
{
234+
relevant_txs.push((tx.clone(), seen_at));
235+
}
236+
}
237+
let graph = self.graph.batch_insert_unconfirmed(relevant_txs);
226238

227239
ChangeSet {
228240
tx_graph: graph,
@@ -278,7 +290,13 @@ where
278290
let mut changeset = ChangeSet::<A, I::ChangeSet>::default();
279291
for (tx_pos, tx) in block.txdata.iter().enumerate() {
280292
changeset.indexer.merge(self.index.index_tx(tx));
281-
if self.index.is_tx_relevant(tx) {
293+
294+
if self.index.is_tx_relevant(tx)
295+
|| self
296+
.graph
297+
.direct_conflicts(tx)
298+
.any(|(_, txid)| self.graph.get_tx(txid).is_some())
299+
{
282300
let txid = tx.compute_txid();
283301
let anchor = TxPosInBlock {
284302
block,

crates/wallet/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ bdk_file_store = { path = "../file_store" }
4747
anyhow = "1"
4848
rand = "^0.8"
4949

50+
# temporary dev-dependencies for testing purposes
51+
bdk_bitcoind_rpc = { path = "../bitcoind_rpc" }
52+
bdk_testenv = { path = "../testenv" }
53+
5054
[package.metadata.docs.rs]
5155
all-features = true
5256
rustdoc-args = ["--cfg", "docsrs"]

crates/wallet/tests/wallet.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4196,8 +4196,125 @@ fn test_transactions_sort_by() {
41964196
assert_eq!([None, Some(2000), Some(1000)], conf_heights.as_slice());
41974197
}
41984198

4199+
use std::collections::HashMap;
4200+
4201+
use bdk_bitcoind_rpc::Emitter;
4202+
use bdk_testenv::bitcoincore_rpc::json::CreateRawTransactionInput;
4203+
use bdk_testenv::bitcoincore_rpc::{RawTx, RpcApi};
4204+
use bdk_testenv::TestEnv;
4205+
use bdk_wallet::CreateParams;
4206+
41994207
#[test]
42004208
fn test_tx_builder_is_send_safe() {
42014209
let (mut wallet, _txid) = get_funded_wallet_wpkh();
42024210
let _box: Box<dyn Send + Sync> = Box::new(wallet.build_tx());
42034211
}
4212+
4213+
// ErikDeSmedt's double spend test scenario
4214+
#[test]
4215+
fn detect_double_spend() -> anyhow::Result<()> {
4216+
// Create a test environment and mine initial funds
4217+
let env = TestEnv::new().expect("Failed to launch bitcoind");
4218+
let client = env
4219+
.bitcoind
4220+
.create_wallet("")
4221+
.expect("Failed to create wallet");
4222+
let address = client
4223+
.get_new_address(None, None)
4224+
.expect("Failed to create address");
4225+
client
4226+
.generate_to_address(106, address.assume_checked_ref())
4227+
.expect("Failed to mine block");
4228+
4229+
// Create the wallet under test
4230+
let mut alice = {
4231+
const DESC: &str ="tr(tprv8ZgxMBicQKsPdnxnfzsvrUZ58eNTq85PA6nhJALQiGy9GVhcvXmHX2r9znpyApMVNLdkPBp3WArLgU3UnA6npK9TtGoZDKdAjjkoYm3rY7F/84'/0'/0'/0/*)";
4232+
Wallet::create_with_params(
4233+
CreateParams::new_single(DESC).network(Network::Regtest)
4234+
)
4235+
}
4236+
.expect("Wallet can be created");
4237+
4238+
// Sync it with the chain
4239+
let mut emitter = Emitter::new(&env.bitcoind.client, alice.latest_checkpoint(), 0);
4240+
while let Some(ev) = emitter.next_block().unwrap() {
4241+
alice
4242+
.apply_block_connected_to(&ev.block, ev.block_height(), ev.connected_to())
4243+
.unwrap();
4244+
}
4245+
4246+
// Creates some transactions
4247+
let unspent = client
4248+
.list_unspent(None, None, None, Some(false), None)
4249+
.unwrap();
4250+
4251+
let input_amount = unspent[0].amount;
4252+
let destination_amount = Amount::from_sat(100_000);
4253+
let fee_amount = Amount::from_sat(2_000);
4254+
let change_amount = input_amount - destination_amount - fee_amount;
4255+
4256+
// Create a transaction that pays Alice
4257+
let address = alice.reveal_next_address(KeychainKind::External).address;
4258+
let change_address = client.get_new_address(None, None).unwrap().assume_checked();
4259+
let inputs = [CreateRawTransactionInput {
4260+
txid: unspent[0].txid,
4261+
vout: unspent[0].vout,
4262+
sequence: Some(0xFFFFFFFE),
4263+
}];
4264+
let mut outputs = HashMap::new();
4265+
outputs.insert(address.to_string(), destination_amount);
4266+
outputs.insert(change_address.to_string(), change_amount);
4267+
let tx1a = client
4268+
.create_raw_transaction(&inputs, &outputs, None, None)
4269+
.unwrap();
4270+
let tx1a = client
4271+
.sign_raw_transaction_with_wallet(tx1a.raw_hex(), None, None)
4272+
.unwrap()
4273+
.transaction()
4274+
.unwrap();
4275+
4276+
// Create a double-spent of tx1a
4277+
let address = client.get_new_address(None, None).unwrap().assume_checked();
4278+
let mut outputs = HashMap::new();
4279+
outputs.insert(address.to_string(), destination_amount);
4280+
outputs.insert(change_address.to_string(), change_amount);
4281+
let tx1b = client
4282+
.create_raw_transaction(&inputs, &outputs, None, None)
4283+
.unwrap();
4284+
let tx1b: Transaction = client
4285+
.sign_raw_transaction_with_wallet(tx1b.raw_hex(), None, None)
4286+
.unwrap()
4287+
.transaction()
4288+
.unwrap();
4289+
4290+
// Alice observes tx1a in the mempool
4291+
alice.apply_unconfirmed_txs(vec![(tx1a.clone(), 100)]);
4292+
4293+
// A block is create
4294+
// In this block tx1a is doublespent by tx1b
4295+
client.send_raw_transaction(tx1b.raw_hex()).unwrap();
4296+
let address = client
4297+
.get_new_address(None, None)
4298+
.expect("Failed to create address");
4299+
client
4300+
.generate_to_address(6, address.assume_checked_ref())
4301+
.expect("Failed to mine block");
4302+
4303+
// Apply the block to the w1
4304+
let mut emitter = Emitter::new(&env.bitcoind.client, alice.latest_checkpoint(), 0);
4305+
while let Some(ev) = emitter.next_block().unwrap() {
4306+
alice
4307+
.apply_block_connected_to(&ev.block, ev.block_height(), ev.connected_to())
4308+
.unwrap();
4309+
}
4310+
4311+
// We also add txb do the wallet
4312+
alice.apply_unconfirmed_txs([(tx1b.clone(), 101)]);
4313+
4314+
// I expect list_unspent to be empty.
4315+
// (tx1a) was double-spent
4316+
assert_eq!(alice.list_unspent().collect::<Vec<_>>(), vec![]);
4317+
//assert_eq!(alice.transactions().collect::<Vec<_>>(), vec![]);
4318+
4319+
Ok(())
4320+
}

0 commit comments

Comments
 (0)