Skip to content

Commit d6593da

Browse files
committed
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 d2271ea commit d6593da

File tree

2 files changed

+196
-17
lines changed

2 files changed

+196
-17
lines changed

crates/chain/src/canonical_view.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ use crate::{
4040
/// This struct represents a transaction that has been determined to be canonical (not
4141
/// conflicted). It includes the transaction itself along with its position in the chain (confirmed
4242
/// or unconfirmed).
43-
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
43+
#[derive(Clone, Debug, PartialEq, Eq)]
4444
pub struct CanonicalTx<A> {
4545
/// The position of this transaction in the chain.
4646
///
@@ -53,6 +53,21 @@ pub struct CanonicalTx<A> {
5353
pub tx: Arc<Transaction>,
5454
}
5555

56+
impl<A: Ord> Ord for CanonicalTx<A> {
57+
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
58+
self.pos
59+
.cmp(&other.pos)
60+
// Txid tiebreaker for same position
61+
.then_with(|| self.txid.cmp(&other.txid))
62+
}
63+
}
64+
65+
impl<A: Ord> PartialOrd for CanonicalTx<A> {
66+
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
67+
Some(self.cmp(other))
68+
}
69+
}
70+
5671
/// A view of canonical transactions from a [`TxGraph`].
5772
///
5873
/// `CanonicalView` provides an ordered, conflict-resolved view of transactions. It determines

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

0 commit comments

Comments
 (0)