Skip to content

Commit 4fe121e

Browse files
committed
Merge #1917: Canonicalization should handle transactions that spend from two conflicting transactions
370497c fix(chain): Correctly handle txs that double spend themselves (志宇) Pull request description: Fixes #1898 ### Description When we initially created the canonicalization algorithm, we didn't expect callers to insert invalid transactions into the graph. However, user error happens and we should handle it correctly. Before this PR, when inserting transactions that double-spend themselves (where two or more different inputs of the transactions conflict), the canonicalization result will have inconsistencies. ### Notes to the reviewers Logic changes are all contained in `CanonicalIter::mark_canonical`. `mark_canonical` will detect whether the `tx` being passed in double spends itself. If so, we abort and undo all changes made so far. There is a slight <2% degradation in performance with this change (except in two cases where there is a performance improvement of ~10%). [bench.txt](https://github.com/user-attachments/files/19788730/bench.txt) ### Changelog notice * Fix canonicalization mess-up when transactions that conflict with itself are inserted. ### Checklists #### All Submissions: * [x] I've signed all my commits * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) * [x] I ran `cargo fmt` and `cargo clippy` before committing #### Bugfixes: ~~* [ ] This pull request breaks the existing API~~ * [x] I've added tests to reproduce the issue which are now passing * [x] I'm linking the issue being fixed by this PR ACKs for top commit: ValuedMammal: ACK 370497c LagginTimes: ACK 370497c Tree-SHA512: 41b08208b3ca7119ff10898c22f91b1fd41bb83876e4ca0655ece13b43db8cf3963a529286282c5fcd0e94d6ae073ffb30b19e91d28d8459957d5592bbacd3c9
2 parents b707586 + 370497c commit 4fe121e

File tree

2 files changed

+162
-15
lines changed

2 files changed

+162
-15
lines changed

crates/chain/src/canonical_iter.rs

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
use crate::collections::{hash_map, HashMap, HashSet, VecDeque};
1+
use crate::collections::{HashMap, HashSet, VecDeque};
22
use crate::tx_graph::{TxAncestors, TxDescendants};
33
use crate::{Anchor, ChainOracle, TxGraph};
44
use alloc::boxed::Box;
55
use alloc::collections::BTreeSet;
66
use alloc::sync::Arc;
7+
use alloc::vec::Vec;
78
use bdk_core::BlockId;
89
use bitcoin::{Transaction, Txid};
910

11+
type CanonicalMap<A> = HashMap<Txid, (Arc<Transaction>, CanonicalReason<A>)>;
12+
type NotCanonicalSet = HashSet<Txid>;
13+
1014
/// Iterates over canonical txs.
1115
pub struct CanonicalIter<'g, A, C> {
1216
tx_graph: &'g TxGraph<A>,
@@ -18,8 +22,8 @@ pub struct CanonicalIter<'g, A, C> {
1822
unprocessed_txs_with_last_seens: Box<dyn Iterator<Item = (Txid, Arc<Transaction>, u64)> + 'g>,
1923
unprocessed_txs_left_over: VecDeque<(Txid, Arc<Transaction>, u32)>,
2024

21-
canonical: HashMap<Txid, (Arc<Transaction>, CanonicalReason<A>)>,
22-
not_canonical: HashSet<Txid>,
25+
canonical: CanonicalMap<A>,
26+
not_canonical: NotCanonicalSet,
2327

2428
queue: VecDeque<Txid>,
2529
}
@@ -87,27 +91,49 @@ impl<'g, A: Anchor, C: ChainOracle> CanonicalIter<'g, A, C> {
8791
Ok(())
8892
}
8993

90-
/// Marks a transaction and it's ancestors as canonical. Mark all conflicts of these as
94+
/// Marks `tx` and it's ancestors as canonical and mark all conflicts of these as
9195
/// `not_canonical`.
96+
///
97+
/// The exception is when it is discovered that `tx` double spends itself (i.e. two of it's
98+
/// inputs conflict with each other), then no changes will be made.
99+
///
100+
/// The logic works by having two loops where one is nested in another.
101+
/// * The outer loop iterates through ancestors of `tx` (including `tx`). We can transitively
102+
/// assume that all ancestors of `tx` are also canonical.
103+
/// * The inner loop loops through conflicts of ancestors of `tx`. Any descendants of conflicts
104+
/// are also conflicts and are transitively considered non-canonical.
105+
///
106+
/// If the inner loop ends up marking `tx` as non-canonical, then we know that it double spends
107+
/// itself.
92108
fn mark_canonical(&mut self, txid: Txid, tx: Arc<Transaction>, reason: CanonicalReason<A>) {
93109
let starting_txid = txid;
94-
let mut is_root = true;
95-
TxAncestors::new_include_root(
110+
let mut is_starting_tx = true;
111+
112+
// We keep track of changes made so far so that we can undo it later in case we detect that
113+
// `tx` double spends itself.
114+
let mut detected_self_double_spend = false;
115+
let mut undo_not_canonical = Vec::<Txid>::new();
116+
117+
// `staged_queue` doubles as the `undo_canonical` data.
118+
let staged_queue = TxAncestors::new_include_root(
96119
self.tx_graph,
97120
tx,
98-
|_: usize, tx: Arc<Transaction>| -> Option<()> {
121+
|_: usize, tx: Arc<Transaction>| -> Option<Txid> {
99122
let this_txid = tx.compute_txid();
100-
let this_reason = if is_root {
101-
is_root = false;
123+
let this_reason = if is_starting_tx {
124+
is_starting_tx = false;
102125
reason.clone()
103126
} else {
104127
reason.to_transitive(starting_txid)
105128
};
129+
130+
use crate::collections::hash_map::Entry;
106131
let canonical_entry = match self.canonical.entry(this_txid) {
107132
// Already visited tx before, exit early.
108-
hash_map::Entry::Occupied(_) => return None,
109-
hash_map::Entry::Vacant(entry) => entry,
133+
Entry::Occupied(_) => return None,
134+
Entry::Vacant(entry) => entry,
110135
};
136+
111137
// Any conflicts with a canonical tx can be added to `not_canonical`. Descendants
112138
// of `not_canonical` txs can also be added to `not_canonical`.
113139
for (_, conflict_txid) in self.tx_graph.direct_conflicts(&tx) {
@@ -116,6 +142,7 @@ impl<'g, A: Anchor, C: ChainOracle> CanonicalIter<'g, A, C> {
116142
conflict_txid,
117143
|_: usize, txid: Txid| -> Option<()> {
118144
if self.not_canonical.insert(txid) {
145+
undo_not_canonical.push(txid);
119146
Some(())
120147
} else {
121148
None
@@ -124,12 +151,28 @@ impl<'g, A: Anchor, C: ChainOracle> CanonicalIter<'g, A, C> {
124151
)
125152
.run_until_finished()
126153
}
154+
155+
if self.not_canonical.contains(&this_txid) {
156+
// Early exit if self-double-spend is detected.
157+
detected_self_double_spend = true;
158+
return None;
159+
}
127160
canonical_entry.insert((tx, this_reason));
128-
self.queue.push_back(this_txid);
129-
Some(())
161+
Some(this_txid)
130162
},
131163
)
132-
.run_until_finished()
164+
.collect::<Vec<Txid>>();
165+
166+
if detected_self_double_spend {
167+
for txid in staged_queue {
168+
self.canonical.remove(&txid);
169+
}
170+
for txid in undo_not_canonical {
171+
self.not_canonical.remove(&txid);
172+
}
173+
} else {
174+
self.queue.extend(staged_queue);
175+
}
133176
}
134177
}
135178

crates/chain/tests/test_tx_graph_conflicts.rs

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,111 @@ fn test_tx_conflict_handling() {
686686
exp_chain_txouts: HashSet::from([("tx", 0)]),
687687
exp_unspents: HashSet::from([("tx", 0)]),
688688
exp_balance: Balance { trusted_pending: Amount::from_sat(9000), ..Default::default() }
689-
}
689+
},
690+
Scenario {
691+
name: "tx spends from 2 conflicting transactions where a conflict spends another",
692+
tx_templates: &[
693+
TxTemplate {
694+
tx_name: "A",
695+
inputs: &[TxInTemplate::Bogus],
696+
outputs: &[TxOutTemplate::new(10_000, None)],
697+
last_seen: Some(1),
698+
..Default::default()
699+
},
700+
TxTemplate {
701+
tx_name: "S1",
702+
inputs: &[TxInTemplate::PrevTx("A", 0)],
703+
outputs: &[TxOutTemplate::new(9_000, None)],
704+
last_seen: Some(2),
705+
..Default::default()
706+
},
707+
TxTemplate {
708+
tx_name: "S2",
709+
inputs: &[TxInTemplate::PrevTx("A", 0), TxInTemplate::PrevTx("S1", 0)],
710+
outputs: &[TxOutTemplate::new(17_000, None)],
711+
last_seen: Some(3),
712+
..Default::default()
713+
},
714+
],
715+
exp_chain_txs: HashSet::from(["A", "S1"]),
716+
exp_chain_txouts: HashSet::from([]),
717+
exp_unspents: HashSet::from([]),
718+
exp_balance: Balance::default(),
719+
},
720+
Scenario {
721+
name: "tx spends from 2 conflicting transactions where the conflict is nested",
722+
tx_templates: &[
723+
TxTemplate {
724+
tx_name: "A",
725+
inputs: &[TxInTemplate::Bogus],
726+
outputs: &[TxOutTemplate::new(10_000, Some(0))],
727+
last_seen: Some(1),
728+
..Default::default()
729+
},
730+
TxTemplate {
731+
tx_name: "S1",
732+
inputs: &[TxInTemplate::PrevTx("A", 0)],
733+
outputs: &[TxOutTemplate::new(9_000, Some(0))],
734+
last_seen: Some(3),
735+
..Default::default()
736+
},
737+
TxTemplate {
738+
tx_name: "B",
739+
inputs: &[TxInTemplate::PrevTx("S1", 0)],
740+
outputs: &[TxOutTemplate::new(8_000, Some(0))],
741+
last_seen: Some(2),
742+
..Default::default()
743+
},
744+
TxTemplate {
745+
tx_name: "S2",
746+
inputs: &[TxInTemplate::PrevTx("B", 0), TxInTemplate::PrevTx("A", 0)],
747+
outputs: &[TxOutTemplate::new(17_000, Some(0))],
748+
last_seen: Some(4),
749+
..Default::default()
750+
},
751+
],
752+
exp_chain_txs: HashSet::from(["A", "S1", "B"]),
753+
exp_chain_txouts: HashSet::from([("A", 0), ("B", 0), ("S1", 0)]),
754+
exp_unspents: HashSet::from([("B", 0)]),
755+
exp_balance: Balance { trusted_pending: Amount::from_sat(8_000), ..Default::default() },
756+
},
757+
Scenario {
758+
name: "tx spends from 2 conflicting transactions where the conflict is nested (different last_seens)",
759+
tx_templates: &[
760+
TxTemplate {
761+
tx_name: "A",
762+
inputs: &[TxInTemplate::Bogus],
763+
outputs: &[TxOutTemplate::new(10_000, Some(0))],
764+
last_seen: Some(1),
765+
..Default::default()
766+
},
767+
TxTemplate {
768+
tx_name: "S1",
769+
inputs: &[TxInTemplate::PrevTx("A", 0)],
770+
outputs: &[TxOutTemplate::new(9_000, Some(0))],
771+
last_seen: Some(4),
772+
..Default::default()
773+
},
774+
TxTemplate {
775+
tx_name: "B",
776+
inputs: &[TxInTemplate::PrevTx("S1", 0)],
777+
outputs: &[TxOutTemplate::new(8_000, Some(0))],
778+
last_seen: Some(2),
779+
..Default::default()
780+
},
781+
TxTemplate {
782+
tx_name: "S2",
783+
inputs: &[TxInTemplate::PrevTx("A", 0), TxInTemplate::PrevTx("B", 0)],
784+
outputs: &[TxOutTemplate::new(17_000, Some(0))],
785+
last_seen: Some(3),
786+
..Default::default()
787+
},
788+
],
789+
exp_chain_txs: HashSet::from(["A", "S1", "B"]),
790+
exp_chain_txouts: HashSet::from([("A", 0), ("B", 0), ("S1", 0)]),
791+
exp_unspents: HashSet::from([("B", 0)]),
792+
exp_balance: Balance { trusted_pending: Amount::from_sat(8_000), ..Default::default() },
793+
},
690794
];
691795

692796
for scenario in scenarios {

0 commit comments

Comments
 (0)