Skip to content

Commit 370497c

Browse files
committed
fix(chain): Correctly handle txs that double spend themselves
Detect self-double-spends in `CanonicalIter::mark_canonical`. Disregard the tx that self-double-spends and all it's attached meta data (such as timestamps and anchors). Add new test cases on `test_tx_conflict_handling` for transactions that self-double-spend.
1 parent b707586 commit 370497c

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)