Skip to content

Commit 539d7bd

Browse files
febyejirandomlogin
andauthored
Cbf fix block fetch (#35)
* bump kyoto version * Add `synced_to_tip` to CbfSyncState Previously we did not track the `FiltersSynced` kyoto event, so we could not tell when we had applied all blocks up to the tip. For example, when we stop and restart the node, kyoto's tip is 0 at the instant of start (it does not persist its chain), so our applied height trivially matches kyoto's tip and we would falsely conclude we had reached it. That is only actually true once we have received `FiltersSynced`. * Add lookahead addresses to `list_revealed_scripts`. Now the function is called `list_watched_scripts`. * Add timeout to block fetch attempts. Previously stalled fetch would hang indefinitely. --------- Co-authored-by: Alexander Shevtsov <randomlogin76@gmail.com>
1 parent 4d18bdc commit 539d7bd

3 files changed

Lines changed: 114 additions & 83 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] }
5858
bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"]}
5959
bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"]}
6060
bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]}
61-
bip157 = { version = "0.6.0", default-features = false }
61+
bip157 = { version = "0.6.1", default-features = false }
6262

6363
bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] }
6464
rustls = { version = "0.23", default-features = false }

src/chain/cbf.rs

Lines changed: 107 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,38 @@ enum CbfRuntimeStatus {
6666

6767
#[derive(Clone, Copy)]
6868
enum CbfSyncState {
69-
Active { applied_tip: Option<u32> },
69+
Active {
70+
/// Highest tip whose preceding chain updates have been applied to all listeners.
71+
applied_tip: Option<u32>,
72+
/// Whether kyoto has reported catching up to the network tip (via `FiltersSynced`) and
73+
/// the resulting blocks have been applied. `wait_until_synced` blocks until this is set.
74+
///
75+
/// This must not be derived from a locally-sampled chain tip: kyoto does not persist, so a
76+
/// freshly (re)started node's local header chain sits at genesis until it syncs from peers.
77+
/// Comparing against that would make `wait_until_synced` return before any sync happens.
78+
synced_to_tip: bool,
79+
},
7080
Failed(Error),
7181
}
7282

83+
/// Marks that we are applying a block past the last `FiltersSynced` tip, so a `sync_wallets` call
84+
/// issued after new blocks are mined waits for the next `FiltersSynced` rather than returning on a
85+
/// stale `synced_to_tip`. Only flips (and notifies waiters) when currently set.
86+
///
87+
/// Called both when a new block's filter is received (before it is fetched and applied) and after
88+
/// it is applied, so `synced_to_tip` reflects "behind by an unapplied block" as soon as we learn
89+
/// that block exists, not only once we've finished catching up to it.
90+
fn mark_syncing(sync_state_tx: &watch::Sender<CbfSyncState>) {
91+
// Copy the current state out and drop the `watch` read guard before calling `send_replace`:
92+
// `borrow()` holds a read lock for the lifetime of its temporary, and `send_replace` takes
93+
// the write lock, so holding the borrow across it deadlocks. `CbfSyncState` is `Copy`, so the
94+
// deref copies and the guard is released at the end of this statement.
95+
let current = *sync_state_tx.borrow();
96+
if let CbfSyncState::Active { applied_tip, synced_to_tip: true } = current {
97+
sync_state_tx.send_replace(CbfSyncState::Active { applied_tip, synced_to_tip: false });
98+
}
99+
}
100+
73101
/// Struct for holding cbf chain source
74102
pub struct CbfChainSource {
75103
/// Trusted peer addresses for kyoto's `Builder::add_peers`.
@@ -91,24 +119,13 @@ pub struct CbfChainSource {
91119
logger: Arc<Logger>,
92120
}
93121

122+
#[derive(Debug)]
94123
enum ChainOp {
95-
ConnectFull {
96-
block: IndexedBlock,
97-
},
98-
ConnectFiltered {
99-
header: Header,
100-
height: u32,
101-
},
102-
Disconnect {
103-
fork_point: BlockLocator,
104-
},
105-
/// Marks reaching the chain tip.
106-
Synced {
107-
tip_height: u32,
108-
},
109-
Failed {
110-
error: Error,
111-
},
124+
ConnectFull { block: IndexedBlock },
125+
ConnectFiltered { header: Header, height: u32 },
126+
Disconnect { fork_point: BlockLocator },
127+
Synced { tip_height: u32 },
128+
Failed { error: Error },
112129
}
113130

114131
struct BlockApplicator {
@@ -140,6 +157,7 @@ impl BlockApplicator {
140157
}
141158
self.chain_listener.block_connected(&ib.block, ib.height);
142159
self.next_height += 1;
160+
mark_syncing(&self.sync_state_tx);
143161
if let Some(cache) = &self.block_fee_cache {
144162
let fee_rate = coinbase_fee_rate(&ib.block, ib.height);
145163
cache
@@ -160,12 +178,14 @@ impl BlockApplicator {
160178
}
161179
self.chain_listener.filtered_block_connected(&header, &[], height);
162180
self.next_height += 1;
181+
mark_syncing(&self.sync_state_tx);
163182
},
164183
ChainOp::Disconnect { fork_point } => {
165184
self.chain_listener.blocks_disconnected(fork_point);
166185
self.next_height = fork_point.height + 1;
167186
self.sync_state_tx.send_replace(CbfSyncState::Active {
168187
applied_tip: Some(fork_point.height),
188+
synced_to_tip: false,
169189
});
170190
},
171191
ChainOp::Synced { tip_height } => {
@@ -180,8 +200,10 @@ impl BlockApplicator {
180200
self.next_height
181201
);
182202
}
203+
log_info!(self.logger, "we set new tip and published at {}", tip_height);
183204
},
184205
ChainOp::Failed { error } => {
206+
log_info!(self.logger, "we received error chain op {}", error);
185207
self.sync_state_tx.send_replace(CbfSyncState::Failed(error));
186208
},
187209
}
@@ -192,14 +214,23 @@ impl BlockApplicator {
192214
let already_published = {
193215
let sync_state = *self.sync_state_tx.borrow();
194216
match sync_state {
195-
CbfSyncState::Active { applied_tip } => applied_tip,
217+
CbfSyncState::Active { applied_tip, .. } => applied_tip,
196218
CbfSyncState::Failed(_) => None,
197219
}
198220
};
199221
if already_published.map_or(false, |published_height| published_height >= tip_height) {
222+
// Even if the applied tip is unchanged, we have now confirmed we are caught up to the
223+
// network tip, so ensure the synced flag is set for any `wait_until_synced` waiter.
224+
self.sync_state_tx.send_replace(CbfSyncState::Active {
225+
applied_tip: already_published,
226+
synced_to_tip: true,
227+
});
200228
return;
201229
}
202-
self.sync_state_tx.send_replace(CbfSyncState::Active { applied_tip: Some(tip_height) });
230+
self.sync_state_tx.send_replace(CbfSyncState::Active {
231+
applied_tip: Some(tip_height),
232+
synced_to_tip: true,
233+
});
203234
let unix_time_secs_opt =
204235
SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs());
205236
if let Err(e) = update_and_persist_node_metrics(
@@ -225,9 +256,12 @@ const FEE_WINDOW_BLOCKS: u32 = BLOCK_FEE_CACHE_CAPACITY as u32;
225256
/// Electrum fee sources. Coinbase-derived rates are frequently zero on regtest/signet.
226257
const CBF_MIN_FEERATE_SAT_PER_KWU: u64 = 250;
227258

228-
/// Per-block timeout when downloading a block to derive its coinbase fee rate. Kept short so a
229-
/// slow peer only delays a single sample rather than the whole fee update.
230-
const CBF_FEE_BLOCK_FETCH_TIMEOUT_SECS: u64 = 10;
259+
/// Per-attempt timeout when downloading a block from a peer — used both for matched blocks we apply
260+
/// to the listeners and for the coinbase-fee-rate samples. Kyoto queues the request and awaits a
261+
/// peer response with no timeout of its own, so a slow or unresponsive peer would otherwise park the
262+
/// fetch forever. Kept short so a single request is bounded and can be retried (or, for fees, only
263+
/// delays one sample) rather than stalling.
264+
const CBF_BLOCK_FETCH_TIMEOUT_SECS: u64 = 10;
231265

232266
/// Recent per-block coinbase-derived fee rates, keyed by height so we can window on the tip, evict
233267
/// stale entries, and detect reorged-out blocks (a height whose cached hash no longer matches the
@@ -276,7 +310,8 @@ impl CbfChainSource {
276310
};
277311
let registered_scripts = Arc::new(Mutex::new(HashSet::new()));
278312
let cbf_runtime_status = Arc::new(Mutex::new(CbfRuntimeStatus::Stopped));
279-
let (sync_state_tx, _) = watch::channel(CbfSyncState::Active { applied_tip: None });
313+
let (sync_state_tx, _) =
314+
watch::channel(CbfSyncState::Active { applied_tip: None, synced_to_tip: false });
280315
Ok(Self {
281316
trusted_peers,
282317
fee_source,
@@ -343,8 +378,10 @@ impl CbfChainSource {
343378
_ => None,
344379
};
345380
let best_block_height = chain_listener.get_best_block().height;
346-
self.sync_state_tx
347-
.send_replace(CbfSyncState::Active { applied_tip: Some(best_block_height) });
381+
self.sync_state_tx.send_replace(CbfSyncState::Active {
382+
applied_tip: Some(best_block_height),
383+
synced_to_tip: false,
384+
});
348385
let block_applicator = BlockApplicator {
349386
next_height: best_block_height + 1,
350387
sync_state_tx: self.sync_state_tx.clone(),
@@ -393,6 +430,7 @@ impl CbfChainSource {
393430
Arc::clone(&restart_cbf_runtime_status),
394431
ops_tx.clone(),
395432
Arc::clone(&restart_listener.onchain_wallet),
433+
restart_sync_state_tx.clone(),
396434
));
397435

398436
match current_node.run().await {
@@ -460,6 +498,7 @@ impl CbfChainSource {
460498
*status = CbfRuntimeStatus::Started { requester: new_requester };
461499
restart_sync_state_tx.send_replace(CbfSyncState::Active {
462500
applied_tip: Some(restart_listener.get_best_block().height),
501+
synced_to_tip: false,
463502
});
464503
}
465504

@@ -495,21 +534,20 @@ impl CbfChainSource {
495534
}
496535

497536
pub(crate) async fn wait_until_synced(&self) -> Result<(), Error> {
498-
let requester = match &*self.cbf_runtime_status.lock().expect("lock") {
499-
CbfRuntimeStatus::Started { requester } => requester.clone(),
500-
CbfRuntimeStatus::Stopped => return Err(Error::NotRunning),
501-
};
502-
let target_tip = requester.chain_tip().await.map_err(|e| {
503-
log_error!(self.logger, "Failed to fetch CBF chain tip before syncing: {:?}", e);
504-
Error::TxSyncFailed
505-
})?;
506-
let target_height = target_tip.height;
537+
if matches!(&*self.cbf_runtime_status.lock().expect("lock"), CbfRuntimeStatus::Stopped) {
538+
return Err(Error::NotRunning);
539+
}
507540
let mut sync_state_rx = self.sync_state_tx.subscribe();
508541

542+
// Wait for kyoto to report catching up to the network tip (a `FiltersSynced`-driven
543+
// `synced_to_tip`) and for the resulting blocks to be applied. We must not target a
544+
// locally-sampled `chain_tip()`: kyoto does not persist, so a freshly (re)started node's
545+
// local header chain sits at genesis until it syncs from peers, which would let this return
546+
// before any sync happens.
509547
loop {
510548
match *sync_state_rx.borrow() {
511-
CbfSyncState::Active { applied_tip } => {
512-
if applied_tip.map_or(false, |applied_height| applied_height >= target_height) {
549+
CbfSyncState::Active { synced_to_tip, .. } => {
550+
if synced_to_tip {
513551
return Ok(());
514552
}
515553
},
@@ -542,11 +580,17 @@ impl CbfChainSource {
542580
logger: Arc<Logger>, mut event_rx: mpsc::UnboundedReceiver<Event>,
543581
registered_scripts: Arc<Mutex<HashSet<ScriptBuf>>>,
544582
cbf_runtime_status: Arc<Mutex<CbfRuntimeStatus>>, ops_tx: mpsc::UnboundedSender<ChainOp>,
545-
onchain_wallet: Arc<Wallet>,
583+
onchain_wallet: Arc<Wallet>, sync_state_tx: watch::Sender<CbfSyncState>,
546584
) {
547585
while let Some(event) = event_rx.recv().await {
548586
match event {
549587
Event::IndexedFilter(indexed_filter) => {
588+
// A new block's filter arrived, so we're behind by at least this block until it
589+
// is fetched (if matched) and applied. Flip this before the fetch, not after,
590+
// so a `sync_wallets` call issued in between doesn't return on a stale
591+
// `synced_to_tip` that predates this block.
592+
mark_syncing(&sync_state_tx);
593+
550594
let requester = match &*cbf_runtime_status.lock().expect("lock") {
551595
CbfRuntimeStatus::Started { requester } => requester.clone(),
552596
CbfRuntimeStatus::Stopped => {
@@ -559,7 +603,7 @@ impl CbfChainSource {
559603
//each time we receive an IndexedFilter event, we ask bdk to give us all
560604
//revealed scripts. We create all_scripts starting from onchain wallet's
561605
//scripts and extend them with LDK's ones
562-
let mut all_scripts = onchain_wallet.list_revealed_scripts();
606+
let mut all_scripts = onchain_wallet.list_watched_scripts();
563607
all_scripts.extend(registered_scripts.lock().expect("lock").iter().cloned());
564608

565609
let block_hash = indexed_filter.block_hash();
@@ -583,42 +627,37 @@ impl CbfChainSource {
583627
},
584628
};
585629

586-
match handle.await {
587-
Ok(Ok(block)) => break block,
588-
Ok(Err(e)) if attempt < CBF_BLOCK_FETCH_RETRIES => {
630+
// Bound the download so an unresponsive peer can't park the fetch forever,
631+
// then flatten the three error layers (timeout / receiver dropped / fetch
632+
// error) into a single reason so the retry-or-fail decision is written once.
633+
let fetched = tokio::time::timeout(
634+
Duration::from_secs(CBF_BLOCK_FETCH_TIMEOUT_SECS),
635+
handle,
636+
)
637+
.await
638+
.map_err(|_| {
639+
format!("timed out after {}s", CBF_BLOCK_FETCH_TIMEOUT_SECS)
640+
})
641+
.and_then(|recv| recv.map_err(|_| "receiver was dropped".to_string()))
642+
.and_then(|fetch| fetch.map_err(|e| format!("failed: {:?}", e)));
643+
644+
match fetched {
645+
Ok(block) => break block,
646+
Err(reason) if attempt < CBF_BLOCK_FETCH_RETRIES => {
589647
log_debug!(
590648
logger,
591-
"CBF block fetch for {} failed on attempt {}: {:?}; retrying",
592-
block_hash,
593-
attempt,
594-
e
595-
);
596-
},
597-
Ok(Err(e)) => {
598-
log_error!(
599-
logger,
600-
"CBF block fetch for {} failed after {} attempts: {:?}",
601-
block_hash,
602-
CBF_BLOCK_FETCH_RETRIES,
603-
e
604-
);
605-
let _ =
606-
ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed });
607-
return;
608-
},
609-
Err(_) if attempt < CBF_BLOCK_FETCH_RETRIES => {
610-
log_debug!(
611-
logger,
612-
"CBF block receiver for {} dropped on attempt {}; retrying",
649+
"CBF block fetch for {} {} on attempt {}; retrying",
613650
block_hash,
651+
reason,
614652
attempt
615653
);
616654
},
617-
Err(_) => {
655+
Err(reason) => {
618656
log_error!(
619657
logger,
620-
"CBF block receiver for {} dropped after {} attempts",
658+
"CBF block fetch for {} {} after {} attempts; giving up",
621659
block_hash,
660+
reason,
622661
CBF_BLOCK_FETCH_RETRIES
623662
);
624663
let _ =
@@ -632,9 +671,7 @@ impl CbfChainSource {
632671
let height = indexed_filter.height();
633672
//TODO we need to recheck that a particular height has not been
634673
//reorganized, and we retrieve indeed the same block header that we
635-
//received `IndexedFilter` event of. right now this would block
636-
//the further sync, as we cannot apply blocks in order.
637-
//Future solution would use something like `get_header_by_hash`.
674+
//received `IndexedFilter` event of.
638675
match requester.get_header(height).await {
639676
Ok(Some(indexed_header)) => {
640677
if indexed_header.block_hash() != block_hash {
@@ -713,10 +750,6 @@ impl CbfChainSource {
713750
self.registered_scripts.lock().expect("lock").insert(output.script_pubkey);
714751
}
715752

716-
// pub(crate) fn register_script(&self, script: ScriptBuf) {
717-
// self.registered_scripts.lock().expect("lock").insert(script);
718-
// }
719-
720753
pub(crate) async fn continuously_update_fee_rate_estimates(
721754
&self, mut stop_sync_receiver: watch::Receiver<()>,
722755
) {
@@ -935,7 +968,7 @@ impl CbfChainSource {
935968
}
936969

937970
match tokio::time::timeout(
938-
Duration::from_secs(CBF_FEE_BLOCK_FETCH_TIMEOUT_SECS),
971+
Duration::from_secs(CBF_BLOCK_FETCH_TIMEOUT_SECS),
939972
requester.average_fee_rate(canonical_hash),
940973
)
941974
.await

src/wallet/mod.rs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -216,14 +216,12 @@ impl Wallet {
216216
Ok(())
217217
}
218218

219-
pub(crate) fn list_revealed_scripts(&self) -> Vec<ScriptBuf> {
220-
self.inner
221-
.lock()
222-
.expect("lock")
223-
.spk_index()
224-
.revealed_spks(..)
225-
.map(|((_keychain, _index), spk)| spk)
226-
.collect()
219+
/// Returns every script pubkey the wallet is watching for on-chain activity: all revealed
220+
/// SPKs plus the lookahead window BDK derives beyond the last revealed index on each keychain.
221+
/// A block may pay an address we have not explicitly revealed yet (e.g. on recovery, where a fresh
222+
/// wallet has revealed nothing) but which is still within the gap limit.
223+
pub(crate) fn list_watched_scripts(&self) -> Vec<ScriptBuf> {
224+
self.inner.lock().expect("lock").spk_index().inner().all_spks().values().cloned().collect()
227225
}
228226

229227
async fn update_payment_store(&self, mut events: Vec<WalletEvent>) -> Result<(), Error> {

0 commit comments

Comments
 (0)