Skip to content

Commit 74cfbc2

Browse files
committed
cbf: add preprocessing step before kyoto runs
A node might go offline on a reorganized block, which would make syncing stall after a restart. This commit adds a preprocessing step which runs once before the events from kyoto are processed. During the preprocessing step we check whether we have forked and disconnect blocks accordingly. Add a test for a deep reorg that happens while the node is offline. Still there might be a problem if several of chain monitors diverged and live on different forks, but other chain sources (bitcoind) ignore that and that perhaps requires a fix upstream.
1 parent d0ab9fc commit 74cfbc2

4 files changed

Lines changed: 186 additions & 35 deletions

File tree

‎src/chain/cbf.rs‎

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
66
use bip157::chain::ChainState;
77
use bip157::{
88
chain::BlockHeaderChanges, Builder as KyotoBuilder, Client, Event as KyotoEvent,
9-
HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer,
10-
Warning,
9+
HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package,
10+
Requester as KyotoRequester, TrustedPeer, Warning,
1111
};
1212
use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Transaction, Txid};
1313
use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder};
@@ -60,7 +60,7 @@ const ELECTRUM_FEE_TIMEOUT_SECS: u64 = 10;
6060

6161
/// Runtime status of the underlying kyoto node.
6262
enum CbfRuntimeStatus {
63-
Started { requester: Requester },
63+
Started { requester: KyotoRequester },
6464
Stopped,
6565
}
6666

@@ -182,9 +182,10 @@ impl BlockApplicator {
182182
},
183183
ChainOp::Disconnect { fork_point } => {
184184
self.chain_listener.blocks_disconnected(fork_point);
185-
self.next_height = fork_point.height + 1;
185+
let best_block_height = self.chain_listener.get_best_block().height;
186+
self.next_height = best_block_height + 1;
186187
self.sync_state_tx.send_replace(CbfSyncState::Active {
187-
applied_tip: Some(fork_point.height),
188+
applied_tip: Some(best_block_height),
188189
synced_to_tip: false,
189190
});
190191
},
@@ -430,9 +431,19 @@ impl CbfChainSource {
430431
Arc::clone(&restart_logger),
431432
));
432433

434+
let (preprocessed_event_tx, preprocessed_event_rx) =
435+
mpsc::unbounded_channel::<KyotoEvent>();
436+
let _ = tokio::spawn(Self::preprocess_kyoto_events(
437+
current_event_rx,
438+
preprocessed_event_tx,
439+
restart_listener.clone(),
440+
Arc::clone(&restart_cbf_runtime_status),
441+
ops_tx.clone(),
442+
));
443+
433444
let event_handle = tokio::spawn(Self::process_kyoto_events(
434445
Arc::clone(&restart_logger),
435-
current_event_rx,
446+
preprocessed_event_rx,
436447
Arc::clone(&restart_registered_scripts),
437448
Arc::clone(&restart_cbf_runtime_status),
438449
ops_tx.clone(),
@@ -583,6 +594,39 @@ impl CbfChainSource {
583594
}
584595
}
585596

597+
//this function is called once per start before we really process the events
598+
//we need it to decide if we have been reorganized during the time we were offline
599+
async fn preprocess_kyoto_events(
600+
mut kyoto_events_rx: mpsc::UnboundedReceiver<KyotoEvent>,
601+
processed_events_tx: mpsc::UnboundedSender<KyotoEvent>, chain_listener: ChainListener,
602+
cbf_runtime_status: Arc<Mutex<CbfRuntimeStatus>>, ops_tx: mpsc::UnboundedSender<ChainOp>,
603+
) {
604+
let mut checked_for_offline_reorg = false;
605+
while let Some(event) = kyoto_events_rx.recv().await {
606+
if !checked_for_offline_reorg {
607+
match &event {
608+
// if we received filter, we're in canonical chain and can check for forks occured
609+
KyotoEvent::IndexedFilter(_) => {
610+
checked_for_offline_reorg = true;
611+
let requester = match &*cbf_runtime_status.lock().expect("lock") {
612+
CbfRuntimeStatus::Started { requester } => requester.clone(),
613+
CbfRuntimeStatus::Stopped => return,
614+
};
615+
if let Some(fork_point) =
616+
startup_fork_point(&requester, &chain_listener).await
617+
{
618+
let _ = ops_tx.send(ChainOp::Disconnect { fork_point });
619+
}
620+
},
621+
_ => {},
622+
}
623+
}
624+
if processed_events_tx.send(event).is_err() {
625+
return;
626+
}
627+
}
628+
}
629+
586630
async fn process_kyoto_events(
587631
logger: Arc<Logger>, mut event_rx: mpsc::UnboundedReceiver<KyotoEvent>,
588632
registered_scripts: Arc<Mutex<HashSet<ScriptBuf>>>,
@@ -930,7 +974,7 @@ impl CbfChainSource {
930974
/// means we have no recent data yet. The window therefore fills incrementally over successive
931975
/// updates rather than requiring all [`FEE_WINDOW_BLOCKS`] downloads to succeed at once.
932976
async fn refresh_block_fee_window(
933-
&self, requester: &Requester, cache: &Mutex<BTreeMap<u32, (BlockHash, FeeRate)>>,
977+
&self, requester: &KyotoRequester, cache: &Mutex<BTreeMap<u32, (BlockHash, FeeRate)>>,
934978
) -> Vec<FeeRate> {
935979
let tip_height = match requester.chain_tip().await {
936980
Ok(tip) => tip.height,
@@ -1003,6 +1047,48 @@ impl CbfChainSource {
10031047
}
10041048
}
10051049

1050+
///Fork point which might have occured when we were offline
1051+
async fn startup_fork_point(
1052+
requester: &KyotoRequester, chain_listener: &ChainListener,
1053+
) -> Option<BlockLocator> {
1054+
let canonical_tip_height = requester.chain_tip().await.ok()?.height;
1055+
1056+
let mut fork_point: Option<BlockLocator> = None;
1057+
for best_block in chain_listener.best_blocks() {
1058+
// The consumer's tip followed by the hashes of the blocks right below it.
1059+
let mut known_blocks = vec![(best_block.height, best_block.block_hash)];
1060+
for (i, previous_hash) in best_block.previous_blocks.iter().enumerate() {
1061+
match (best_block.height.checked_sub(i as u32 + 1), previous_hash) {
1062+
(Some(height), Some(hash)) => known_blocks.push((height, *hash)),
1063+
_ => break,
1064+
}
1065+
}
1066+
1067+
for (height, hash) in known_blocks {
1068+
if height > canonical_tip_height {
1069+
continue;
1070+
}
1071+
let canonical_header = match requester.get_header(height).await.ok().flatten() {
1072+
Some(header) => header,
1073+
// Kyoto has no headers below its checkpoint.
1074+
None => break,
1075+
};
1076+
if canonical_header.block_hash() != hash {
1077+
continue;
1078+
}
1079+
// The consumer's last canonical block. If it is its tip, nothing needs disconnecting.
1080+
if height < best_block.height {
1081+
match fork_point {
1082+
Some(lowest) if lowest.height <= height => {},
1083+
_ => fork_point = Some(BlockLocator::new(hash, height)),
1084+
}
1085+
}
1086+
break;
1087+
}
1088+
}
1089+
fork_point
1090+
}
1091+
10061092
fn resume_checkpoint(logger: &Logger, chain_listener: &ChainListener) -> Option<HashCheckpoint> {
10071093
let min_best_block = chain_listener.get_best_block();
10081094
let bdk_cp = chain_listener.onchain_wallet.upgrade()?.latest_checkpoint();

‎src/chain/mod.rs‎

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ use std::sync::{Arc, Mutex};
2121
use std::time::Duration;
2222

2323
use bitcoin::{Script, Txid};
24-
use lightning::chain::{BlockLocator, Filter};
2524
#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))]
2625
use lightning::chain::Listen;
26+
use lightning::chain::{BlockLocator, Filter};
2727

2828
#[cfg(feature = "chain-bitcoind")]
2929
use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient};
@@ -207,27 +207,26 @@ impl ChainListener {
207207

208208
#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))]
209209
impl ChainListener {
210-
pub(crate) fn get_best_block(&self) -> BlockLocator {
210+
pub(crate) fn best_blocks(&self) -> Vec<BlockLocator> {
211211
let (onchain_wallet, channel_manager, chain_monitor, output_sweeper) =
212212
self.upgrade().expect("chain listener components dropped");
213-
let candidates = [
213+
let mut best_blocks = vec![
214214
onchain_wallet.current_best_block(),
215215
channel_manager.current_best_block(),
216216
output_sweeper.current_best_block(),
217217
];
218-
let mut min = candidates.into_iter().min_by_key(|b| b.height).expect("non-empty");
219-
if let Some(worst_monitor) = chain_monitor
220-
.list_monitors()
221-
.iter()
222-
.flat_map(|id| chain_monitor.get_monitor(*id))
223-
.map(|m| m.current_best_block())
224-
.min_by_key(|b| b.height)
225-
{
226-
if worst_monitor.height < min.height {
227-
min = worst_monitor;
228-
}
229-
}
230-
min
218+
best_blocks.extend(
219+
chain_monitor
220+
.list_monitors()
221+
.iter()
222+
.flat_map(|id| chain_monitor.get_monitor(*id))
223+
.map(|m| m.current_best_block()),
224+
);
225+
best_blocks
226+
}
227+
228+
pub(crate) fn get_best_block(&self) -> BlockLocator {
229+
self.best_blocks().into_iter().min_by_key(|b| b.height).expect("non-empty")
231230
}
232231
}
233232

@@ -283,7 +282,20 @@ impl Listen for ChainListener {
283282
if channel_manager.current_best_block().height > fork_point_block.height {
284283
channel_manager.blocks_disconnected(fork_point_block);
285284
}
286-
chain_monitor.blocks_disconnected(fork_point_block);
285+
// Monitors are persisted at different heights, and `ChainMonitor::blocks_disconnected`
286+
// asserts every one of them is above the fork point, so disconnect them one by one.
287+
for monitor in
288+
chain_monitor.list_monitors().iter().flat_map(|id| chain_monitor.get_monitor(*id))
289+
{
290+
if monitor.current_best_block().height > fork_point_block.height {
291+
monitor.blocks_disconnected(
292+
fork_point_block,
293+
onchain_wallet.broadcaster(),
294+
onchain_wallet.fee_estimator(),
295+
onchain_wallet.logger(),
296+
);
297+
}
298+
}
287299
if output_sweeper.current_best_block().height > fork_point_block.height {
288300
output_sweeper.blocks_disconnected(fork_point_block);
289301
}
@@ -730,9 +742,7 @@ impl ChainSource {
730742
bitcoind_chain_source.update_fee_rate_estimates().await
731743
},
732744
#[cfg(feature = "chain-cbf")]
733-
ChainSourceKind::Cbf(cbf_chain_source) => {
734-
cbf_chain_source.update_fee_rate_estimates().await
735-
},
745+
ChainSourceKind::Cbf(cbf_chain_source) => cbf_chain_source.update_fee_rate_estimates().await,
736746
}
737747
}
738748

‎src/wallet/mod.rs‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,21 @@ impl Wallet {
235235
self.inner.lock().expect("lock").latest_checkpoint()
236236
}
237237

238+
#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))]
239+
pub(crate) fn broadcaster(&self) -> &Arc<Broadcaster> {
240+
&self.broadcaster
241+
}
242+
243+
#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))]
244+
pub(crate) fn fee_estimator(&self) -> &Arc<OnchainFeeEstimator> {
245+
&self.fee_estimator
246+
}
247+
248+
#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))]
249+
pub(crate) fn logger(&self) -> &Arc<Logger> {
250+
&self.logger
251+
}
252+
238253
#[cfg(any(feature = "chain-bitcoind", feature = "chain-cbf"))]
239254
pub(crate) fn current_best_block(&self) -> BlockLocator {
240255
let checkpoint = self.inner.lock().expect("lock").latest_checkpoint();
@@ -469,8 +484,12 @@ impl Wallet {
469484
let current = existing?;
470485
match current.kind {
471486
PaymentKind::Onchain {
472-
status: ConfirmationStatus::Confirmed { height, .. }, ..
473-
} if new_tip.height >= height + ANTI_REORG_DELAY - 1 => {
487+
status:
488+
ConfirmationStatus::Confirmed { height, .. },
489+
..
490+
} if new_tip.height
491+
>= height + ANTI_REORG_DELAY - 1 =>
492+
{
474493
graduated = true;
475494
let mut update =
476495
PaymentDetailsUpdate::new(payment_id);
@@ -527,7 +546,8 @@ impl Wallet {
527546
let mut locked_wallet = self.inner.lock().expect("lock");
528547
locked_wallet.apply_evicted_txs_events(evicted_txids)
529548
};
530-
Box::pin(self.update_payment_store(eviction_events, reference_time)).await?;
549+
Box::pin(self.update_payment_store(eviction_events, reference_time))
550+
.await?;
531551
}
532552

533553
if !unconfirmed_outbound_txids.is_empty() {
@@ -4576,9 +4596,9 @@ mod tests {
45764596
wallet.update_payment_store(vec![event], reference_time),
45774597
)
45784598
.await;
4579-
assert!(
4580-
result.expect("eviction must not wait on the persister lock held by its caller").is_ok()
4581-
);
4599+
assert!(result
4600+
.expect("eviction must not wait on the persister lock held by its caller")
4601+
.is_ok());
45824602

45834603
let payment = wallet.payment_store.get(&payment_id).await.unwrap().unwrap();
45844604
assert_eq!(payment.status, PaymentStatus::Failed);

‎tests/reorg_test.rs‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ use serde_json::json;
1212
use crate::common::{
1313
expect_event, exponential_backoff_poll, generate_blocks_and_wait, invalidate_blocks,
1414
open_channel, premine_and_distribute_funds, random_chain_source, random_config,
15-
setup_bitcoind_and_electrsd, setup_node, wait_for_outpoint_spend, wait_for_tx, NodePaymentExt,
16-
TestChainSource,
15+
setup_bitcoind_and_electrsd, setup_node, wait_for_node_tip, wait_for_outpoint_spend,
16+
wait_for_tx, NodePaymentExt, TestChainSource, TestStoreType,
1717
};
1818

1919
#[cfg(feature = "chain-bitcoind")]
@@ -72,6 +72,41 @@ fn bitcoind_rest_follows_valid_reorg() {
7272
})
7373
}
7474

75+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
76+
async fn restart_follows_deep_reorg_that_happened_while_offline() {
77+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
78+
let chain_source = random_chain_source(&bitcoind, &electrsd);
79+
let mut config = random_config();
80+
config.store_type = TestStoreType::Sqlite;
81+
let node = setup_node(&chain_source, config.clone());
82+
83+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 50).await;
84+
node.sync_wallets().unwrap();
85+
let old_tip = node.status().current_best_block;
86+
87+
node.stop().unwrap();
88+
drop(node);
89+
90+
let reorg_depth = 5;
91+
invalidate_blocks(&bitcoind.client, reorg_depth);
92+
let new_tip_height =
93+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, reorg_depth + 20).await;
94+
let new_tip_hash = bitcoind.client.best_block_hash().expect("failed to get new tip hash");
95+
assert_ne!(old_tip.block_hash, new_tip_hash);
96+
97+
let node = setup_node(&chain_source, config);
98+
wait_for_node_tip(&node, new_tip_height)
99+
.await
100+
.expect("restarted node did not reach the new tip after an offline reorg");
101+
node.sync_wallets().unwrap();
102+
103+
let synced_tip = node.status().current_best_block;
104+
assert_eq!(synced_tip.height as usize, new_tip_height);
105+
assert_eq!(synced_tip.block_hash, new_tip_hash);
106+
107+
node.stop().unwrap();
108+
}
109+
75110
async fn wait_for_pending_sweep_balance<F>(
76111
node: &ldk_node::Node, mut matches_balance: F,
77112
) -> PendingSweepBalance

0 commit comments

Comments
 (0)