Skip to content

Commit 3ef748f

Browse files
committed
Merge #2146: chain 0.23.x: Fix ChainPosition ordering
aff800d fix(chain): correct unconfirmed `ChainPosition` `last_seen` tiebreaker (志宇) 9d2dedc fix(chain): correct ChainPosition ordering for wallet transaction display (志宇) Pull request description: ## Description Back-ported from #2074 Depends on #2148 This PR fixes incorrect ordering of `ChainPosition` that affected how wallet transactions are displayed. Previously, unconfirmed transactions that were never seen in the mempool would incorrectly appear before transactions with mempool timestamps due to the derived `Ord` implementation treating `None` as less than `Some(_)`. ### Problem The derived `Ord` implementation for `ChainPosition::Unconfirmed` caused incorrect transaction ordering: - `Unconfirmed { first_seen: None, last_seen: None }` (never in mempool) - Would appear **before** `Unconfirmed { first_seen: Some(10), last_seen: Some(10) }` (seen in mempool) This resulted in a confusing wallet display where transactions never broadcast would appear before pending mempool transactions. ### Solution Implemented manual `Ord` and `PartialOrd` traits for `ChainPosition` with the correct ordering: 1. **Confirmed transactions** (earliest first by block height) 2. **Unconfirmed transactions seen in mempool** (ordered by `first_seen`) 3. **Unconfirmed transactions never seen** (these come last) Additional ordering rules: - At the same confirmation height, transitive confirmations come before direct confirmations (as they may actually be confirmed earlier) - Tie-breaking uses transaction IDs for deterministic ordering ## Changelog notice ```md ### Fixed - Fixed `ChainPosition` ordering so unconfirmed transactions never seen in mempool appear last instead of first ### Changed - `ChainPosition`, `CanonicalTx`, and `FullTxOut` now require `A: Ord` for their `Ord` implementations - Simplified `FullTxOut` ordering to only use essential fields (chain_position, outpoint, spent_by) ``` ## Checklists ### All Submissions: * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) ### Bugfixes: * [ ] This pull request breaks the existing API * [x] I've added tests to reproduce the issue which are now passing * [ ] I'm linking the issue being fixed by this PR Top commit has no ACKs. Tree-SHA512: eccdf30a47c7e8ef40312f1dd4bf72ce42a3e26a1dd1e6d1f7dc4776350c99029408474439730256414bd0f173e755977a3e3deb81617a930e40ca7962bd490b
2 parents ecfe271 + aff800d commit 3ef748f

2 files changed

Lines changed: 205 additions & 17 deletions

File tree

crates/chain/src/chain_data.rs

Lines changed: 189 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,84 @@ 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+
/// Compares options where `None` is greater than `Some` (sorts last).
100+
fn cmp_none_last<T: Ord>(t1: &Option<T>, t2: &Option<T>) -> Ordering {
101+
match (t1, t2) {
102+
(None, None) => Ordering::Equal,
103+
(None, Some(_)) => Ordering::Greater,
104+
(Some(_), None) => Ordering::Less,
105+
(Some(t1), Some(t2)) => t1.cmp(t2),
106+
}
107+
}
108+
109+
match (self, other) {
110+
// Both confirmed: compare by anchor first
111+
(
112+
ChainPosition::Confirmed {
113+
anchor: a1,
114+
transitively: t1,
115+
},
116+
ChainPosition::Confirmed {
117+
anchor: a2,
118+
transitively: t2,
119+
},
120+
) => {
121+
// First compare anchors
122+
match a1.cmp(a2) {
123+
// Same anchor: transitive before direct, tiebreak with txid
124+
Ordering::Equal => cmp_none_last(t1, t2),
125+
other => other,
126+
}
127+
}
128+
129+
// Both unconfirmed: special handling for None values
130+
(
131+
ChainPosition::Unconfirmed {
132+
first_seen: f1,
133+
last_seen: l1,
134+
},
135+
ChainPosition::Unconfirmed {
136+
first_seen: f2,
137+
last_seen: l2,
138+
},
139+
) => {
140+
// Never-in-mempool (None, None) ordered last
141+
// Compare by first_seen, tie-break with last_seen
142+
match cmp_none_last(f1, f2) {
143+
Ordering::Equal => cmp_none_last(l1, l2),
144+
other => other,
145+
}
146+
}
147+
148+
// Confirmed always comes before unconfirmed
149+
(ChainPosition::Confirmed { .. }, ChainPosition::Unconfirmed { .. }) => Ordering::Less,
150+
(ChainPosition::Unconfirmed { .. }, ChainPosition::Confirmed { .. }) => {
151+
Ordering::Greater
152+
}
153+
}
154+
}
155+
}
156+
157+
/// Partial ordering for `ChainPosition` - delegates to `Ord` implementation.
158+
impl<A: Ord> PartialOrd for ChainPosition<A> {
159+
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
160+
Some(self.cmp(other))
161+
}
162+
}
163+
88164
/// A `TxOut` with as much data as we can retrieve about it
89-
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
165+
#[derive(Debug, Clone, PartialEq, Eq)]
90166
pub struct FullTxOut<A> {
91167
/// The position of the transaction in `outpoint` in the overall chain.
92168
pub chain_position: ChainPosition<A>,
@@ -100,6 +176,22 @@ pub struct FullTxOut<A> {
100176
pub is_on_coinbase: bool,
101177
}
102178

179+
impl<A: Ord> Ord for FullTxOut<A> {
180+
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
181+
self.chain_position
182+
.cmp(&other.chain_position)
183+
// Tie-break with `outpoint` and `spent_by`.
184+
.then_with(|| self.outpoint.cmp(&other.outpoint))
185+
.then_with(|| self.spent_by.cmp(&other.spent_by))
186+
}
187+
}
188+
189+
impl<A: Ord> PartialOrd for FullTxOut<A> {
190+
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
191+
Some(self.cmp(other))
192+
}
193+
}
194+
103195
impl<A: Anchor> FullTxOut<A> {
104196
/// Whether the `txout` is considered mature.
105197
///
@@ -167,22 +259,16 @@ impl<A: Anchor> FullTxOut<A> {
167259
#[cfg_attr(coverage_nightly, coverage(off))]
168260
mod test {
169261
use bdk_core::ConfirmationBlockTime;
262+
use bitcoin::hashes::Hash;
170263

171264
use crate::BlockId;
172265

173266
use super::*;
174267

175268
#[test]
176269
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 {
270+
// Create test positions
271+
let conf_deep = ChainPosition::Confirmed {
186272
anchor: ConfirmationBlockTime {
187273
confirmation_time: 20,
188274
block_id: BlockId {
@@ -192,7 +278,17 @@ mod test {
192278
},
193279
transitively: None,
194280
};
195-
let conf2 = ChainPosition::Confirmed {
281+
let conf_deep_transitive = ChainPosition::Confirmed {
282+
anchor: ConfirmationBlockTime {
283+
confirmation_time: 20,
284+
block_id: BlockId {
285+
height: 9,
286+
..Default::default()
287+
},
288+
},
289+
transitively: Some(Txid::all_zeros()),
290+
};
291+
let conf_shallow = ChainPosition::Confirmed {
196292
anchor: ConfirmationBlockTime {
197293
confirmation_time: 15,
198294
block_id: BlockId {
@@ -202,12 +298,89 @@ mod test {
202298
},
203299
transitively: None,
204300
};
301+
let unconf_seen_early = ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
302+
first_seen: Some(10),
303+
last_seen: Some(10),
304+
};
305+
let unconf_seen_late = ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
306+
first_seen: Some(20),
307+
last_seen: Some(20),
308+
};
309+
let unconf_seen_early_and_late = ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
310+
first_seen: Some(10),
311+
last_seen: Some(20),
312+
};
313+
let unconf_never_seen = ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
314+
first_seen: None,
315+
last_seen: None,
316+
};
317+
318+
// Test ordering: confirmed < unconfirmed
319+
assert!(
320+
conf_deep < unconf_seen_early,
321+
"confirmed comes before unconfirmed"
322+
);
323+
assert!(
324+
conf_shallow < unconf_seen_early,
325+
"confirmed comes before unconfirmed"
326+
);
205327

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

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)