Skip to content

Commit 05faaf6

Browse files
committed
Add CBF documentation, on-chain reorg handling, and CI coverage
1 parent 62f6557 commit 05faaf6

6 files changed

Lines changed: 261 additions & 42 deletions

File tree

.github/workflows/rust.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,17 @@ jobs:
7272
- name: Build with UniFFI support on Rust ${{ matrix.toolchain }}
7373
if: matrix.build-uniffi
7474
run: cargo build --features uniffi --verbose --color always
75+
- name: Build with CBF and UniFFI support on Rust ${{ matrix.toolchain }}
76+
if: matrix.build-uniffi
77+
run: cargo build --features "cbf uniffi" --verbose --color always
7578
- name: Check release build on Rust ${{ matrix.toolchain }}
7679
run: cargo check --release --verbose --color always
7780
- name: Check release build with UniFFI support on Rust ${{ matrix.toolchain }}
7881
if: matrix.build-uniffi
7982
run: cargo check --release --features uniffi --verbose --color always
83+
- name: Check release build with CBF and UniFFI support on Rust ${{ matrix.toolchain }}
84+
if: matrix.build-uniffi
85+
run: cargo check --release --features "cbf uniffi" --verbose --color always
8086
- name: Test on Rust ${{ matrix.toolchain }}
8187
if: "matrix.platform != 'windows-latest'"
8288
run: |
@@ -85,6 +91,14 @@ jobs:
8591
if: "matrix.platform != 'windows-latest' && matrix.build-uniffi"
8692
run: |
8793
RUSTFLAGS="--cfg no_download --cfg cycle_tests" cargo test --features uniffi
94+
- name: Test with CBF and UniFFI support on Rust ${{ matrix.toolchain }}
95+
if: "matrix.platform != 'windows-latest' && matrix.build-uniffi"
96+
run: |
97+
RUSTFLAGS="--cfg no_download --cfg cycle_tests" cargo test --features "cbf uniffi" --lib arced_builder_can_set_cbf_chain_source
98+
- name: Test with CBF support on Rust ${{ matrix.toolchain }}
99+
if: "matrix.platform != 'windows-latest'"
100+
run: |
101+
RUSTFLAGS="--cfg no_download --cfg cycle_tests" cargo test --features cbf
88102
89103
doc:
90104
name: Documentation

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ fn main() {
6161
LDK Node currently comes with a decidedly opinionated set of design choices:
6262

6363
- On-chain data is handled by the integrated [BDK][bdk] wallet.
64-
- Chain data may currently be sourced from the Bitcoin Core RPC interface, or from an [Electrum][electrum] or [Esplora][esplora] server.
64+
- Chain data may currently be sourced from the Bitcoin Core RPC interface, from an [Electrum][electrum] or [Esplora][esplora] server, or via [compact block filters (BIP 157)][bip157] when the `cbf` feature is enabled.
6565
- Wallet and channel state may be persisted to an [SQLite][sqlite] database, to file system, or to a custom back-end to be implemented by the user.
6666
- Gossip data may be sourced via Lightning's peer-to-peer network or the [Rapid Gossip Sync](https://docs.rs/lightning-rapid-gossip-sync/*/lightning_rapid_gossip_sync/) protocol.
6767
- Entropy for the Lightning and on-chain wallets may be sourced from raw bytes or a [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) mnemonic. In addition, LDK Node offers the means to generate and persist the entropy bytes to disk.
@@ -80,6 +80,7 @@ The Minimum Supported Rust Version (MSRV) is currently 1.85.0.
8080
[bdk]: https://bitcoindevkit.org/
8181
[electrum]: https://github.com/spesmilo/electrum-protocol
8282
[esplora]: https://github.com/Blockstream/esplora
83+
[bip157]: https://github.com/bitcoin/bips/blob/master/bip-0157.mediawiki
8384
[sqlite]: https://sqlite.org/
8485
[rust]: https://www.rust-lang.org/
8586
[swift]: https://www.swift.org/

src/chain/cbf.rs

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,9 @@ impl CbfChainSource {
264264
reorganized.len(),
265265
accepted.len(),
266266
);
267+
*state.latest_tip.lock().unwrap() = accepted
268+
.last()
269+
.map(|indexed_header| indexed_header.header.block_hash());
267270
let reorg_hashes = reorganized.iter().map(|h| h.block_hash()).collect();
268271
state
269272
.reorg_queue
@@ -398,15 +401,50 @@ impl CbfChainSource {
398401
},
399402
};
400403

401-
// Build chain checkpoint extending from the wallet's current tip.
402-
let mut cp = onchain_wallet.latest_checkpoint();
403-
for (height, header) in sync_update.recent_history() {
404-
if *height > cp.height() {
405-
let block_id = BlockId { height: *height, hash: header.block_hash() };
406-
cp = cp.push(block_id).unwrap_or_else(|old| old);
407-
}
408-
}
409404
let tip = sync_update.tip();
405+
let current_cp = onchain_wallet.latest_checkpoint();
406+
let reorg_detected = tip.height < current_cp.height()
407+
|| sync_update.recent_history().iter().any(|(height, header)| {
408+
current_cp.get(*height).is_some_and(|cp| cp.hash() != header.block_hash())
409+
});
410+
411+
let mut cp = if reorg_detected {
412+
log_debug!(
413+
self.logger,
414+
"Detected on-chain CBF reorg while rebuilding checkpoints at tip {}.",
415+
tip.hash
416+
);
417+
let base_cp = sync_update
418+
.recent_history()
419+
.iter()
420+
.find_map(|(height, header)| {
421+
current_cp.get(*height).filter(|cp| cp.hash() == header.block_hash())
422+
})
423+
.unwrap_or_else(|| {
424+
current_cp
425+
.iter()
426+
.last()
427+
.expect("wallet checkpoint should always include genesis")
428+
});
429+
let mut rebuilt_cp = base_cp;
430+
for (height, header) in sync_update.recent_history() {
431+
if *height > rebuilt_cp.height() {
432+
let block_id = BlockId { height: *height, hash: header.block_hash() };
433+
rebuilt_cp = rebuilt_cp.push(block_id).unwrap_or_else(|old| old);
434+
}
435+
}
436+
rebuilt_cp
437+
} else {
438+
// Build chain checkpoint extending from the wallet's current tip.
439+
let mut extended_cp = current_cp;
440+
for (height, header) in sync_update.recent_history() {
441+
if *height > extended_cp.height() {
442+
let block_id = BlockId { height: *height, hash: header.block_hash() };
443+
extended_cp = extended_cp.push(block_id).unwrap_or_else(|old| old);
444+
}
445+
}
446+
extended_cp
447+
};
410448
if tip.height > cp.height() {
411449
let tip_block_id = BlockId { height: tip.height, hash: tip.hash };
412450
cp = cp.push(tip_block_id).unwrap_or_else(|old| old);
@@ -674,28 +712,51 @@ impl CbfChainSource {
674712
);
675713
let fetch_start = Instant::now();
676714

677-
for _ in 0..FEE_RATE_LOOKBACK_BLOCKS {
715+
for idx in 0..FEE_RATE_LOOKBACK_BLOCKS {
678716
let remaining_timeout = timeout.saturating_sub(fetch_start.elapsed());
679717
if remaining_timeout.is_zero() {
680718
log_error!(self.logger, "Updating fee rate estimates timed out.");
681719
return Err(Error::FeerateEstimationUpdateTimeout);
682720
}
683721

684722
let indexed_block =
685-
tokio::time::timeout(remaining_timeout, requester.get_block(current_hash))
723+
match tokio::time::timeout(remaining_timeout, requester.get_block(current_hash))
686724
.await
687-
.map_err(|e| {
688-
log_error!(self.logger, "Updating fee rate estimates timed out: {}", e);
689-
Error::FeerateEstimationUpdateTimeout
690-
})?
691-
.map_err(|e| {
725+
{
726+
Ok(Ok(indexed_block)) => indexed_block,
727+
Ok(Err(e)) if idx == 0 => {
728+
log_debug!(
729+
self.logger,
730+
"Cached CBF tip {} was unavailable during fee estimation, likely due to a reorg: {:?}",
731+
current_hash,
732+
e
733+
);
734+
*self.latest_tip.lock().unwrap() = None;
735+
return Ok(());
736+
},
737+
Ok(Err(e)) => {
692738
log_error!(
693739
self.logger,
694740
"Failed to fetch block for fee estimation: {:?}",
695741
e
696742
);
697-
Error::FeerateEstimationUpdateFailed
698-
})?;
743+
return Err(Error::FeerateEstimationUpdateFailed);
744+
},
745+
Err(e) if idx == 0 => {
746+
log_debug!(
747+
self.logger,
748+
"Timed out fetching cached CBF tip {} during fee estimation, likely due to a reorg: {}",
749+
current_hash,
750+
e
751+
);
752+
*self.latest_tip.lock().unwrap() = None;
753+
return Ok(());
754+
},
755+
Err(e) => {
756+
log_error!(self.logger, "Updating fee rate estimates timed out: {}", e);
757+
return Err(Error::FeerateEstimationUpdateTimeout);
758+
},
759+
};
699760

700761
let height = indexed_block.height;
701762
let block = &indexed_block.block;

src/wallet/mod.rs

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -320,23 +320,61 @@ impl Wallet {
320320

321321
for mut payment in pending_payments {
322322
match payment.details.kind {
323-
PaymentKind::Onchain {
324-
status: ConfirmationStatus::Confirmed { height, .. },
325-
..
326-
} => {
323+
PaymentKind::Onchain { txid, .. } => {
324+
let current_confirmation_status = locked_wallet
325+
.tx_details(txid)
326+
.map(|tx_details| match tx_details.chain_position {
327+
bdk_chain::ChainPosition::Confirmed { anchor, .. } => {
328+
ConfirmationStatus::Confirmed {
329+
block_hash: anchor.block_id.hash,
330+
height: anchor.block_id.height,
331+
timestamp: anchor.confirmation_time,
332+
}
333+
},
334+
bdk_chain::ChainPosition::Unconfirmed { .. } => {
335+
ConfirmationStatus::Unconfirmed
336+
},
337+
});
327338
let payment_id = payment.details.id;
328-
if new_tip.height >= height + ANTI_REORG_DELAY - 1 {
329-
payment.details.status = PaymentStatus::Succeeded;
330-
self.payment_store.insert_or_update(payment.details)?;
331-
self.pending_payment_store.remove(&payment_id)?;
339+
match current_confirmation_status {
340+
Some(ConfirmationStatus::Confirmed {
341+
block_hash,
342+
height,
343+
timestamp,
344+
}) => {
345+
payment.details.kind = PaymentKind::Onchain {
346+
txid,
347+
status: ConfirmationStatus::Confirmed {
348+
block_hash,
349+
height,
350+
timestamp,
351+
},
352+
};
353+
if new_tip.height >= height + ANTI_REORG_DELAY - 1 {
354+
payment.details.status = PaymentStatus::Succeeded;
355+
self.payment_store.insert_or_update(payment.details)?;
356+
self.pending_payment_store.remove(&payment_id)?;
357+
} else {
358+
self.payment_store
359+
.insert_or_update(payment.details.clone())?;
360+
self.pending_payment_store.insert_or_update(payment)?;
361+
}
362+
},
363+
Some(ConfirmationStatus::Unconfirmed) | None => {
364+
payment.details.kind = PaymentKind::Onchain {
365+
txid,
366+
status: ConfirmationStatus::Unconfirmed,
367+
};
368+
payment.details.status = PaymentStatus::Pending;
369+
if payment.details.direction == PaymentDirection::Outbound {
370+
unconfirmed_outbound_txids.push(txid);
371+
}
372+
self.payment_store
373+
.insert_or_update(payment.details.clone())?;
374+
self.pending_payment_store.insert_or_update(payment)?;
375+
},
332376
}
333377
},
334-
PaymentKind::Onchain {
335-
txid,
336-
status: ConfirmationStatus::Unconfirmed,
337-
} if payment.details.direction == PaymentDirection::Outbound => {
338-
unconfirmed_outbound_txids.push(txid);
339-
},
340378
_ => {},
341379
}
342380
}

tests/common/mod.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -477,12 +477,9 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) ->
477477
TestChainSource::Cbf(bitcoind) => {
478478
let p2p_socket = bitcoind.params.p2p_socket.expect("P2P must be enabled for CBF");
479479
let peer_addr = format!("{}", p2p_socket);
480-
let sync_config = CbfSyncConfig {
481-
background_sync_config: None,
482-
timeouts_config: Default::default(),
483-
peers: vec![peer_addr],
484-
};
485-
builder.set_chain_source_cbf(Some(sync_config));
480+
let sync_config =
481+
CbfSyncConfig { background_sync_config: None, timeouts_config: Default::default() };
482+
builder.set_chain_source_cbf(vec![peer_addr], Some(sync_config));
486483
},
487484
}
488485

tests/integration_tests_rust.rs

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ use common::{
2121
expect_channel_pending_event, expect_channel_ready_event, expect_channel_ready_events,
2222
expect_event, expect_payment_claimable_event, expect_payment_received_event,
2323
expect_payment_successful_event, expect_splice_pending_event, generate_blocks_and_wait,
24-
open_channel, open_channel_push_amt, open_channel_with_all, premine_and_distribute_funds,
25-
premine_blocks, prepare_rbf, random_chain_source, random_config, random_listening_addresses,
26-
setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all,
27-
wait_for_tx, TestChainSource, TestStoreType, TestSyncStore,
24+
invalidate_blocks, open_channel, open_channel_push_amt, open_channel_with_all,
25+
premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config,
26+
random_listening_addresses, setup_bitcoind_and_electrsd, setup_builder, setup_node,
27+
setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, TestChainSource,
28+
TestStoreType, TestSyncStore,
2829
};
2930
use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig};
3031
use ldk_node::entropy::NodeEntropy;
@@ -2940,3 +2941,110 @@ async fn onchain_wallet_sync_cbf() {
29402941
node_a.stop().unwrap();
29412942
node_b.stop().unwrap();
29422943
}
2944+
2945+
#[cfg(feature = "cbf")]
2946+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
2947+
async fn onchain_wallet_sync_cbf_after_reorg() {
2948+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
2949+
let chain_source = TestChainSource::Cbf(&bitcoind);
2950+
let node = setup_node(&chain_source, random_config(true));
2951+
2952+
let first_addr = node.onchain_payment().new_address().unwrap();
2953+
let first_amount_sat = 100_000;
2954+
premine_and_distribute_funds(
2955+
&bitcoind.client,
2956+
&electrsd.client,
2957+
vec![first_addr],
2958+
Amount::from_sat(first_amount_sat),
2959+
)
2960+
.await;
2961+
2962+
wait_for_cbf_filters().await;
2963+
node.sync_wallets().unwrap();
2964+
assert_eq!(node.list_balances().spendable_onchain_balance_sats, first_amount_sat);
2965+
2966+
// Advance the tip so the reorg happens below our synced checkpoint.
2967+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 2).await;
2968+
wait_for_cbf_filters().await;
2969+
node.sync_wallets().unwrap();
2970+
2971+
// Replace the last two blocks with a different branch that has no wallet activity.
2972+
invalidate_blocks(&bitcoind.client, 2);
2973+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 2).await;
2974+
wait_for_cbf_filters().await;
2975+
2976+
let second_addr = node.onchain_payment().new_address().unwrap();
2977+
let second_amount_sat = 50_000;
2978+
distribute_funds_unconfirmed(
2979+
&bitcoind.client,
2980+
&electrsd.client,
2981+
vec![second_addr],
2982+
Amount::from_sat(second_amount_sat),
2983+
)
2984+
.await;
2985+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await;
2986+
wait_for_cbf_filters().await;
2987+
2988+
node.sync_wallets().unwrap();
2989+
2990+
assert_eq!(
2991+
node.list_balances().spendable_onchain_balance_sats,
2992+
first_amount_sat + second_amount_sat
2993+
);
2994+
assert_eq!(
2995+
node.list_payments_with_filter(|p| {
2996+
p.direction == PaymentDirection::Inbound
2997+
&& matches!(p.kind, PaymentKind::Onchain { .. })
2998+
})
2999+
.len(),
3000+
2
3001+
);
3002+
3003+
node.stop().unwrap();
3004+
}
3005+
3006+
#[cfg(feature = "cbf")]
3007+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
3008+
async fn onchain_wallet_sync_cbf_reorgs_out_confirmed_receive() {
3009+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
3010+
let chain_source = TestChainSource::Cbf(&bitcoind);
3011+
let node = setup_node(&chain_source, random_config(true));
3012+
3013+
let addr = node.onchain_payment().new_address().unwrap();
3014+
premine_blocks(&bitcoind.client, &electrsd.client).await;
3015+
let cur_height = bitcoind.client.get_blockchain_info().unwrap().blocks as usize;
3016+
let reward_block_hash =
3017+
bitcoind.client.generate_to_address(1, &addr).unwrap().0.pop().unwrap().parse().unwrap();
3018+
wait_for_block(&electrsd.client, cur_height + 1).await;
3019+
let reward_block = bitcoind.client.get_block(reward_block_hash).unwrap();
3020+
let txid = reward_block.txdata[0].compute_txid();
3021+
wait_for_cbf_filters().await;
3022+
3023+
node.sync_wallets().unwrap();
3024+
assert_eq!(node.list_balances().total_onchain_balance_sats, 5_000_000_000);
3025+
assert_eq!(node.list_balances().spendable_onchain_balance_sats, 0);
3026+
3027+
let payment_id = PaymentId(txid.to_byte_array());
3028+
let payment = node.payment(&payment_id).unwrap();
3029+
assert_eq!(payment.status, PaymentStatus::Pending);
3030+
match payment.kind {
3031+
PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } => {},
3032+
other => panic!("Unexpected payment state before reorg: {:?}", other),
3033+
}
3034+
3035+
invalidate_blocks(&bitcoind.client, 1);
3036+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 2).await;
3037+
wait_for_cbf_filters().await;
3038+
node.sync_wallets().unwrap();
3039+
3040+
assert_eq!(node.list_balances().total_onchain_balance_sats, 0);
3041+
assert_eq!(node.list_balances().spendable_onchain_balance_sats, 0);
3042+
let payment = node.payment(&payment_id).unwrap();
3043+
assert_eq!(payment.status, PaymentStatus::Pending);
3044+
match payment.kind {
3045+
PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } => {},
3046+
other => panic!("Unexpected payment state after reorg: {:?}", other),
3047+
}
3048+
3049+
node.stop().unwrap();
3050+
}

0 commit comments

Comments
 (0)