Skip to content

Commit be93147

Browse files
evanlinjinoleonardolima
authored andcommitted
fix(chain): correct ChainPosition ordering for wallet transaction display
Previously, unconfirmed transactions never seen in mempool would appear before those with mempool timestamps due to derived Ord implementation. Changes: - Manual Ord impl: confirmed < unconfirmed < never-in-mempool - At same height: transitive confirmations < direct (potentially earlier) - Simplify FullTxOut ordering to only essential fields - Add comprehensive tests and documentation - Update CanonicalTx and FullTxOut to use manual Ord with A: Ord bound
1 parent d8be40c commit be93147

2 files changed

Lines changed: 196 additions & 17 deletions

File tree

crates/chain/src/chain_data.rs

Lines changed: 180 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::Anchor;
55
/// Represents the observed position of some chain data.
66
///
77
/// The generic `A` should be a [`Anchor`] implementation.
8-
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, core::hash::Hash)]
8+
#[derive(Debug, Clone, Copy, PartialEq, Eq, core::hash::Hash)]
99
#[cfg_attr(
1010
feature = "serde",
1111
derive(serde::Deserialize, serde::Serialize),
@@ -85,8 +85,86 @@ impl<A: Anchor> ChainPosition<A> {
8585
}
8686
}
8787

88+
/// Ordering for `ChainPosition`:
89+
///
90+
/// 1. Confirmed transactions come before unconfirmed
91+
/// 2. Confirmed transactions are ordered by anchor (lower height = earlier)
92+
/// 3. At equal anchor height, transitive confirmations come before direct
93+
/// 4. Unconfirmed transactions with mempool timestamps come before those without
94+
/// 5. Unconfirmed transactions are ordered by `first_seen`, then `last_seen`
95+
impl<A: Ord> Ord for ChainPosition<A> {
96+
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
97+
use core::cmp::Ordering;
98+
99+
match (self, other) {
100+
// Both confirmed: compare by anchor first
101+
(
102+
ChainPosition::Confirmed {
103+
anchor: a1,
104+
transitively: t1,
105+
},
106+
ChainPosition::Confirmed {
107+
anchor: a2,
108+
transitively: t2,
109+
},
110+
) => {
111+
// First compare anchors
112+
match a1.cmp(a2) {
113+
Ordering::Equal => {
114+
// Same anchor: transitive before direct (may be earlier)
115+
match (t1, t2) {
116+
(None, None) => Ordering::Equal,
117+
(None, Some(_)) => Ordering::Greater, // Direct comes after transitive
118+
(Some(_), None) => Ordering::Less, // Transitive comes before direct
119+
// Both transitive: txid tiebreaker
120+
(Some(tx1), Some(tx2)) => tx1.cmp(tx2),
121+
}
122+
}
123+
other => other,
124+
}
125+
}
126+
127+
// Both unconfirmed: special handling for None values
128+
(
129+
ChainPosition::Unconfirmed {
130+
first_seen: f1,
131+
last_seen: l1,
132+
},
133+
ChainPosition::Unconfirmed {
134+
first_seen: f2,
135+
last_seen: l2,
136+
},
137+
) => {
138+
// Never-in-mempool (None, None) ordered last
139+
match (f1.or(*l1), f2.or(*l2)) {
140+
(None, None) => Ordering::Equal,
141+
(None, Some(_)) => Ordering::Greater, // Never-seen after seen
142+
(Some(_), None) => Ordering::Less, // Seen before never-seen
143+
(Some(_), Some(_)) => {
144+
// Both seen: compare first_seen, then last_seen
145+
f1.cmp(f2).then_with(|| l1.cmp(l2))
146+
}
147+
}
148+
}
149+
150+
// Confirmed always comes before unconfirmed
151+
(ChainPosition::Confirmed { .. }, ChainPosition::Unconfirmed { .. }) => Ordering::Less,
152+
(ChainPosition::Unconfirmed { .. }, ChainPosition::Confirmed { .. }) => {
153+
Ordering::Greater
154+
}
155+
}
156+
}
157+
}
158+
159+
/// Partial ordering for `ChainPosition` - delegates to `Ord` implementation.
160+
impl<A: Ord> PartialOrd for ChainPosition<A> {
161+
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
162+
Some(self.cmp(other))
163+
}
164+
}
165+
88166
/// A `TxOut` with as much data as we can retrieve about it
89-
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
167+
#[derive(Debug, Clone, PartialEq, Eq)]
90168
pub struct FullTxOut<A> {
91169
/// The position of the transaction in `outpoint` in the overall chain.
92170
pub chain_position: ChainPosition<A>,
@@ -100,6 +178,22 @@ pub struct FullTxOut<A> {
100178
pub is_on_coinbase: bool,
101179
}
102180

181+
impl<A: Ord> Ord for FullTxOut<A> {
182+
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
183+
self.chain_position
184+
.cmp(&other.chain_position)
185+
// Tie-break with `outpoint` and `spent_by`.
186+
.then_with(|| self.outpoint.cmp(&other.outpoint))
187+
.then_with(|| self.spent_by.cmp(&other.spent_by))
188+
}
189+
}
190+
191+
impl<A: Ord> PartialOrd for FullTxOut<A> {
192+
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
193+
Some(self.cmp(other))
194+
}
195+
}
196+
103197
impl<A: Anchor> FullTxOut<A> {
104198
/// Whether the `txout` is considered mature.
105199
///
@@ -167,22 +261,16 @@ impl<A: Anchor> FullTxOut<A> {
167261
#[cfg_attr(coverage_nightly, coverage(off))]
168262
mod test {
169263
use bdk_core::ConfirmationBlockTime;
264+
use bitcoin::hashes::Hash;
170265

171266
use crate::BlockId;
172267

173268
use super::*;
174269

175270
#[test]
176271
fn chain_position_ord() {
177-
let unconf1 = ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
178-
last_seen: Some(10),
179-
first_seen: Some(10),
180-
};
181-
let unconf2 = ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
182-
last_seen: Some(20),
183-
first_seen: Some(20),
184-
};
185-
let conf1 = ChainPosition::Confirmed {
272+
// Create test positions
273+
let conf_deep = ChainPosition::Confirmed {
186274
anchor: ConfirmationBlockTime {
187275
confirmation_time: 20,
188276
block_id: BlockId {
@@ -192,7 +280,17 @@ mod test {
192280
},
193281
transitively: None,
194282
};
195-
let conf2 = ChainPosition::Confirmed {
283+
let conf_deep_transitive = ChainPosition::Confirmed {
284+
anchor: ConfirmationBlockTime {
285+
confirmation_time: 20,
286+
block_id: BlockId {
287+
height: 9,
288+
..Default::default()
289+
},
290+
},
291+
transitively: Some(Txid::all_zeros()),
292+
};
293+
let conf_shallow = ChainPosition::Confirmed {
196294
anchor: ConfirmationBlockTime {
197295
confirmation_time: 15,
198296
block_id: BlockId {
@@ -202,12 +300,78 @@ mod test {
202300
},
203301
transitively: None,
204302
};
303+
let unconf_seen_early = ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
304+
first_seen: Some(10),
305+
last_seen: Some(10),
306+
};
307+
let unconf_seen_late = ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
308+
first_seen: Some(20),
309+
last_seen: Some(20),
310+
};
311+
let unconf_never_seen = ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
312+
first_seen: None,
313+
last_seen: None,
314+
};
315+
316+
// Test ordering: confirmed < unconfirmed
317+
assert!(
318+
conf_deep < unconf_seen_early,
319+
"confirmed comes before unconfirmed"
320+
);
321+
assert!(
322+
conf_shallow < unconf_seen_early,
323+
"confirmed comes before unconfirmed"
324+
);
205325

206-
assert!(unconf2 > unconf1, "higher last_seen means higher ord");
207-
assert!(unconf1 > conf1, "unconfirmed is higher ord than confirmed");
326+
// Test ordering within confirmed: lower height (more confirmations) comes first
208327
assert!(
209-
conf2 > conf1,
210-
"confirmation_height is higher then it should be higher ord"
328+
conf_deep < conf_shallow,
329+
"deeper blocks (lower height) come first"
330+
);
331+
332+
// Test ordering within confirmed at same height: transitive comes before direct
333+
assert!(
334+
conf_deep_transitive < conf_deep,
335+
"transitive confirmation comes before direct at same height"
336+
);
337+
338+
// Test ordering within unconfirmed: earlier first_seen comes first
339+
assert!(
340+
unconf_seen_early < unconf_seen_late,
341+
"earlier first_seen comes first"
342+
);
343+
344+
// Test ordering: never seen in mempool comes last
345+
assert!(
346+
unconf_seen_early < unconf_never_seen,
347+
"seen in mempool comes before never seen"
348+
);
349+
assert!(
350+
unconf_seen_late < unconf_never_seen,
351+
"seen in mempool comes before never seen"
352+
);
353+
354+
// Full ordering test: most confirmed -> least confirmed -> unconfirmed seen -> unconfirmed
355+
// never seen
356+
let mut positions = vec![
357+
unconf_never_seen,
358+
unconf_seen_late,
359+
conf_shallow,
360+
unconf_seen_early,
361+
conf_deep,
362+
conf_deep_transitive,
363+
];
364+
positions.sort();
365+
assert_eq!(
366+
positions,
367+
vec![
368+
conf_deep_transitive, // Most confirmed (potentially)
369+
conf_deep, // Deep confirmation
370+
conf_shallow, // Shallow confirmation
371+
unconf_seen_early, // Unconfirmed, seen early
372+
unconf_seen_late, // Unconfirmed, seen late
373+
unconf_never_seen, // Never in mempool
374+
]
211375
);
212376
}
213377

crates/chain/src/tx_graph.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,14 +247,29 @@ impl Default for TxNodeInternal {
247247
}
248248

249249
/// A transaction that is deemed to be part of the canonical history.
250-
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
250+
#[derive(Clone, Debug, PartialEq, Eq)]
251251
pub struct CanonicalTx<'a, T, A> {
252252
/// How the transaction is observed in the canonical chain (confirmed or unconfirmed).
253253
pub chain_position: ChainPosition<A>,
254254
/// The transaction node (as part of the graph).
255255
pub tx_node: TxNode<'a, T, A>,
256256
}
257257

258+
impl<'a, T: Ord, A: Ord> Ord for CanonicalTx<'a, T, A> {
259+
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
260+
self.chain_position
261+
.cmp(&other.chain_position)
262+
// Txid tiebreaker for same position
263+
.then_with(|| self.tx_node.txid.cmp(&other.tx_node.txid))
264+
}
265+
}
266+
267+
impl<'a, T: Ord, A: Ord> PartialOrd for CanonicalTx<'a, T, A> {
268+
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
269+
Some(self.cmp(other))
270+
}
271+
}
272+
258273
impl<'a, T, A> From<CanonicalTx<'a, T, A>> for Txid {
259274
fn from(tx: CanonicalTx<'a, T, A>) -> Self {
260275
tx.tx_node.txid

0 commit comments

Comments
 (0)