Skip to content

Commit c037f35

Browse files
evanlinjinclaude
andcommitted
refactor(core,chain): extract BlockQueries::resolve_candidates
Both canonicalization phases checked a tx's anchors against fetched blocks with identical logic: scan for a resolved, matching block; else request the missing heights; else wait on in-flight queries; else give up. The two copies had already drifted (the phase-2 copy still carried the old `has_unresolved_heights` shape with a latent empty-`Query` edge). Extract that logic as `BlockQueries::resolve_candidates`, returning a `BlockCandidateResolution` enum (`Confirmed` / `Query` / `Awaiting` / `NotConfirmed`). It lives on `BlockQueries` since it is an operation on that type, and is decoupled from `Anchor`: it is generic over the candidate `A` and takes a `Fn(&A) -> BlockId` closure, requiring only what it uses (a `BlockId` per candidate). Each call site now maps the four outcomes onto its own queue handling and `TaskProgress`. Adds unit tests covering all four outcomes, including the `Awaiting` branch that the synchronous driver never hits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1afd0da commit c037f35

3 files changed

Lines changed: 206 additions & 86 deletions

File tree

crates/chain/src/canonical_task.rs

Lines changed: 30 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ use alloc::boxed::Box;
55
use alloc::collections::BTreeSet;
66
use alloc::sync::Arc;
77
use alloc::vec::Vec;
8-
use bdk_core::{BlockId, BlockQueries, ChainTask, TaskProgress, ToBlockHash};
8+
use bdk_core::{
9+
BlockCandidateResolution, BlockId, BlockQueries, ChainTask, TaskProgress, ToBlockHash,
10+
};
911
use bitcoin::{Transaction, Txid};
1012

1113
type CanonicalMap<A> = HashMap<Txid, CanonicalEntry<CanonicalReason<A>>>;
@@ -131,58 +133,36 @@ impl<'g, A: Anchor, B: ToBlockHash> ChainTask<B> for CanonicalTask<'g, A, B> {
131133
return TaskProgress::Advanced;
132134
}
133135

134-
let mut best_anchor = Option::<A>::None;
135-
for a in anchors.iter() {
136-
let h = a.anchor_block().height;
137-
// A block that is either unresolved (`None`), absent at this height
138-
// (`Some(None)`), or present but mismatched does not confirm this anchor.
139-
if let Some(Some(b)) = self.queries.get(h) {
140-
if b.to_blockhash() == a.anchor_block().hash {
141-
best_anchor = Some(a.clone());
142-
break;
143-
}
144-
}
145-
}
146-
147-
if let Some(a) = best_anchor {
148-
self.mark_canonical(txid, tx, CanonicalReason::from_anchor(a));
149-
return TaskProgress::Advanced;
150-
}
151-
152-
// No resolved anchor matched. `request` returns only heights that are neither
153-
// resolved nor already in-flight, so a non-empty result is genuinely new work
154-
// to fetch. This keeps `Query` additive and never empty.
155-
let heights = self
136+
match self
156137
.queries
157-
.request(anchors.iter().map(|a| a.anchor_block().height));
158-
if !heights.is_empty() {
159-
self.unprocessed_anchored_txs
160-
.push_front((txid, tx, anchors));
161-
return TaskProgress::Query(heights);
162-
}
163-
164-
// No new heights to request. If any anchor height is still in-flight (requested
165-
// but unresolved), we can't decide this tx yet — wait for the driver. Otherwise
166-
// every anchor height is resolved and none matched, so the tx is not confirmed.
167-
if anchors
168-
.iter()
169-
.any(|a| self.queries.get(a.anchor_block().height).is_none())
138+
.resolve_candidates(anchors, |a| a.anchor_block())
170139
{
171-
self.unprocessed_anchored_txs
172-
.push_front((txid, tx, anchors));
173-
return TaskProgress::AwaitingQueries;
140+
BlockCandidateResolution::Confirmed(a) => {
141+
self.mark_canonical(txid, tx, CanonicalReason::from_anchor(a));
142+
}
143+
BlockCandidateResolution::Query(heights) => {
144+
self.unprocessed_anchored_txs
145+
.push_front((txid, tx, anchors));
146+
return TaskProgress::Query(heights);
147+
}
148+
BlockCandidateResolution::Awaiting => {
149+
self.unprocessed_anchored_txs
150+
.push_front((txid, tx, anchors));
151+
return TaskProgress::AwaitingQueries;
152+
}
153+
BlockCandidateResolution::NotConfirmed => {
154+
// No anchor confirms this tx; defer to the leftover stage.
155+
self.unprocessed_leftover_txs.push_back((
156+
txid,
157+
tx,
158+
anchors
159+
.iter()
160+
.last()
161+
.expect("must have at least one anchor")
162+
.confirmation_height_upper_bound(),
163+
));
164+
}
174165
}
175-
176-
// No confirmed anchor found.
177-
self.unprocessed_leftover_txs.push_back((
178-
txid,
179-
tx,
180-
anchors
181-
.iter()
182-
.last()
183-
.expect("must have at least one anchor")
184-
.confirmation_height_upper_bound(),
185-
));
186166
} else {
187167
self.current_stage.next_stage();
188168
}

crates/chain/src/canonical_view_task.rs

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ use crate::tx_graph::TxDescendants;
66
use alloc::collections::BTreeSet;
77
use alloc::vec::Vec;
88

9-
use bdk_core::{BlockId, BlockQueries, ChainTask, TaskProgress, ToBlockHash, ToBlockTime};
9+
use bdk_core::{
10+
BlockCandidateResolution, BlockId, BlockQueries, ChainTask, TaskProgress, ToBlockHash,
11+
ToBlockTime,
12+
};
1013
use bitcoin::{OutPoint, Txid};
1114

1215
use crate::{canonical::CanonicalEntry, Anchor, CanonicalView, ChainPosition, TxGraph};
@@ -132,44 +135,24 @@ impl<'g, A: Anchor, B: ToBlockHash> ChainTask<B> for CanonicalViewTask<'g, A, B>
132135
match self.current_stage {
133136
ViewStage::ResolvingPositions => {
134137
if let Some((txid, anchors)) = self.unprocessed_anchor_checks.pop_front() {
135-
let mut best_anchor = Option::<A>::None;
136-
for a in anchors.iter() {
137-
let h = a.anchor_block().height;
138-
// Only a resolved, present, matching block confirms this anchor.
139-
if let Some(Some(b)) = self.queries.get(h) {
140-
if b.to_blockhash() == a.anchor_block().hash {
141-
best_anchor = Some(a.clone());
142-
break;
143-
}
144-
}
145-
}
146-
147-
if let Some(anchor) = best_anchor {
148-
self.direct_anchors.insert(txid, anchor);
149-
return TaskProgress::Advanced;
150-
}
151-
152-
// No resolved anchor matched. `request` is additive, so a non-empty result is
153-
// genuinely new work to fetch and is never empty.
154-
let heights = self
138+
match self
155139
.queries
156-
.request(anchors.iter().map(|a| a.anchor_block().height));
157-
if !heights.is_empty() {
158-
self.unprocessed_anchor_checks.push_front((txid, anchors));
159-
return TaskProgress::Query(heights);
160-
}
161-
162-
// No new heights. If any anchor height is still in-flight, wait for the driver;
163-
// otherwise all are resolved and none matched (no direct anchor for this tx).
164-
if anchors
165-
.iter()
166-
.any(|a| self.queries.get(a.anchor_block().height).is_none())
140+
.resolve_candidates(anchors, |a| a.anchor_block())
167141
{
168-
self.unprocessed_anchor_checks.push_front((txid, anchors));
169-
return TaskProgress::AwaitingQueries;
142+
BlockCandidateResolution::Confirmed(anchor) => {
143+
self.direct_anchors.insert(txid, anchor);
144+
}
145+
BlockCandidateResolution::Query(heights) => {
146+
self.unprocessed_anchor_checks.push_front((txid, anchors));
147+
return TaskProgress::Query(heights);
148+
}
149+
BlockCandidateResolution::Awaiting => {
150+
self.unprocessed_anchor_checks.push_front((txid, anchors));
151+
return TaskProgress::AwaitingQueries;
152+
}
153+
// No anchor confirms this tx; leave it without a direct anchor.
154+
BlockCandidateResolution::NotConfirmed => {}
170155
}
171-
172-
// No confirmed anchor found for this tx
173156
TaskProgress::Advanced
174157
} else {
175158
self.current_stage = ViewStage::FetchingMtpBlocks;

crates/core/src/block_queries.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::collections::BTreeMap;
2+
use crate::{BlockId, ToBlockHash};
23
use alloc::collections::BTreeSet;
34
use alloc::vec::Vec;
45

@@ -60,8 +61,164 @@ impl<B> BlockQueries<B> {
6061
}
6162
}
6263

64+
impl<B: ToBlockHash> BlockQueries<B> {
65+
/// Resolves a set of candidate blocks against the blocks fetched so far.
66+
///
67+
/// Each candidate's [`BlockId`] is obtained via `block_id`. A candidate is *in-chain* when the
68+
/// block resolved at its height is present and its hash matches the candidate's; the first such
69+
/// candidate is returned as [`Confirmed`](BlockCandidateResolution::Confirmed).
70+
///
71+
/// When no candidate is confirmed, the result tells the caller how to make progress:
72+
/// - [`Query`](BlockCandidateResolution::Query) — heights that still need fetching. These come
73+
/// from [`request`](Self::request), so the list is *additive* (each height announced once)
74+
/// and never empty.
75+
/// - [`Awaiting`](BlockCandidateResolution::Awaiting) — nothing new to request, but some
76+
/// heights are still in-flight; wait for them to resolve.
77+
/// - [`NotConfirmed`](BlockCandidateResolution::NotConfirmed) — every candidate height is
78+
/// resolved and none matched.
79+
pub fn resolve_candidates<A: Clone>(
80+
&mut self,
81+
candidates: &BTreeSet<A>,
82+
block_id: impl Fn(&A) -> BlockId,
83+
) -> BlockCandidateResolution<A> {
84+
// A candidate is confirmed only by a resolved, present block whose hash matches. Unresolved
85+
// (`None`), absent (`Some(None)`), or mismatched blocks do not confirm it.
86+
for c in candidates.iter() {
87+
let id = block_id(c);
88+
if let Some(Some(b)) = self.get(id.height) {
89+
if b.to_blockhash() == id.hash {
90+
return BlockCandidateResolution::Confirmed(c.clone());
91+
}
92+
}
93+
}
94+
95+
let heights = self.request(candidates.iter().map(|c| block_id(c).height));
96+
if !heights.is_empty() {
97+
return BlockCandidateResolution::Query(heights);
98+
}
99+
100+
// Nothing new to request: wait if any candidate height is still in-flight, otherwise all
101+
// are resolved and none matched.
102+
if candidates
103+
.iter()
104+
.any(|c| self.get(block_id(c).height).is_none())
105+
{
106+
return BlockCandidateResolution::Awaiting;
107+
}
108+
109+
BlockCandidateResolution::NotConfirmed
110+
}
111+
}
112+
63113
impl<B> Default for BlockQueries<B> {
64114
fn default() -> Self {
65115
Self::new()
66116
}
67117
}
118+
119+
/// Outcome of [`BlockQueries::resolve_candidates`].
120+
///
121+
/// A "candidate" is some value `A` that claims to live at a particular [`BlockId`] (height +
122+
/// hash). Resolution checks those claims against the blocks fetched so far and reports which of
123+
/// four states applies. The generic `A` is carried through opaquely so the confirmed candidate can
124+
/// be returned to the caller.
125+
#[derive(Debug, Clone, PartialEq, Eq)]
126+
pub enum BlockCandidateResolution<A> {
127+
/// One candidate's block is resolved and its hash matches — this candidate is in-chain.
128+
Confirmed(A),
129+
/// These *newly requested* heights must be fetched before the candidates can be decided.
130+
/// Always non-empty.
131+
Query(Vec<u32>),
132+
/// Nothing new to request, but some candidate heights are still in-flight; the candidates
133+
/// can't be decided until the driver resolves them.
134+
Awaiting,
135+
/// Every candidate height is resolved and none match — no candidate is in-chain.
136+
NotConfirmed,
137+
}
138+
139+
#[cfg(test)]
140+
mod test {
141+
use super::*;
142+
use alloc::vec;
143+
use bitcoin::{hashes::Hash, BlockHash};
144+
145+
fn bhash(n: u8) -> BlockHash {
146+
BlockHash::from_byte_array([n; 32])
147+
}
148+
149+
fn id(height: u32, hash: u8) -> BlockId {
150+
BlockId {
151+
height,
152+
hash: bhash(hash),
153+
}
154+
}
155+
156+
fn candidates<const N: usize>(ids: [BlockId; N]) -> BTreeSet<BlockId> {
157+
BTreeSet::from(ids)
158+
}
159+
160+
#[test]
161+
fn confirmed_when_block_resolved_and_matches() {
162+
let mut q = BlockQueries::<BlockHash>::new();
163+
q.request([5].into_iter());
164+
q.resolve(5, Some(bhash(5)));
165+
166+
let cands = candidates([id(5, 5)]);
167+
let res = q.resolve_candidates(&cands, |c| *c);
168+
assert_eq!(res, BlockCandidateResolution::Confirmed(id(5, 5)));
169+
}
170+
171+
#[test]
172+
fn confirmed_picks_matching_candidate_among_many() {
173+
let mut q = BlockQueries::<BlockHash>::new();
174+
q.request([4, 5].into_iter());
175+
q.resolve(4, Some(bhash(99))); // height 4 resolved but mismatched
176+
q.resolve(5, Some(bhash(5))); // height 5 matches
177+
178+
let cands = candidates([id(4, 4), id(5, 5)]);
179+
let res = q.resolve_candidates(&cands, |c| *c);
180+
assert_eq!(res, BlockCandidateResolution::Confirmed(id(5, 5)));
181+
}
182+
183+
#[test]
184+
fn query_returns_unrequested_heights() {
185+
let mut q = BlockQueries::<BlockHash>::new();
186+
let cands = candidates([id(7, 7)]);
187+
let res = q.resolve_candidates(&cands, |c| *c);
188+
assert_eq!(res, BlockCandidateResolution::Query(vec![7]));
189+
// The request is recorded, so it is now reported as outstanding.
190+
assert!(q.unresolved().eq([7]));
191+
}
192+
193+
#[test]
194+
fn awaiting_when_heights_in_flight() {
195+
let mut q = BlockQueries::<BlockHash>::new();
196+
q.request([7].into_iter()); // requested but not resolved
197+
198+
let cands = candidates([id(7, 7)]);
199+
let res = q.resolve_candidates(&cands, |c| *c);
200+
assert_eq!(res, BlockCandidateResolution::Awaiting);
201+
}
202+
203+
#[test]
204+
fn not_confirmed_when_resolved_and_mismatched() {
205+
let mut q = BlockQueries::<BlockHash>::new();
206+
q.request([7].into_iter());
207+
q.resolve(7, Some(bhash(99))); // present but wrong hash
208+
209+
let cands = candidates([id(7, 7)]);
210+
let res = q.resolve_candidates(&cands, |c| *c);
211+
assert_eq!(res, BlockCandidateResolution::NotConfirmed);
212+
}
213+
214+
#[test]
215+
fn not_confirmed_when_block_absent() {
216+
let mut q = BlockQueries::<BlockHash>::new();
217+
q.request([7].into_iter());
218+
q.resolve(7, None); // chain source has no block at this height
219+
220+
let cands = candidates([id(7, 7)]);
221+
let res = q.resolve_candidates(&cands, |c| *c);
222+
assert_eq!(res, BlockCandidateResolution::NotConfirmed);
223+
}
224+
}

0 commit comments

Comments
 (0)