From e57927a914b3872606e6167cb3e19cbfb6a59208 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Fri, 15 Aug 2025 09:59:49 -0300 Subject: [PATCH] Add reorg property test and refactor funding logic - Introduced a property test to verify reorg handling during both channel opening and closing (normal and force close). - Added `invalidate_block` helper to roll back the chain and regenerate blocks to the previous height. --- tests/common/mod.rs | 38 +++++++-- tests/reorg_test.rs | 193 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 tests/reorg_test.rs diff --git a/tests/common/mod.rs b/tests/common/mod.rs index daed86475..ab66f0fdd 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -40,7 +40,9 @@ use electrum_client::ElectrumApi; use rand::distributions::Alphanumeric; use rand::{thread_rng, Rng}; +use serde_json::{json, Value}; +use std::collections::HashMap; use std::env; use std::path::PathBuf; use std::sync::{Arc, RwLock}; @@ -395,6 +397,21 @@ pub(crate) fn generate_blocks_and_wait( println!("\n"); } +pub(crate) fn invalidate_blocks(bitcoind: &BitcoindClient, num_blocks: usize) { + let blockchain_info = bitcoind.get_blockchain_info().expect("failed to get blockchain info"); + let cur_height = blockchain_info.blocks as usize; + let target_height = cur_height - num_blocks + 1; + let block_hash = bitcoind + .get_block_hash(target_height as u64) + .expect("failed to get block hash") + .block_hash() + .expect("block hash should be present"); + bitcoind.invalidate_block(block_hash).expect("failed to invalidate block"); + let blockchain_info = bitcoind.get_blockchain_info().expect("failed to get blockchain info"); + let new_cur_height = blockchain_info.blocks as usize; + assert!(new_cur_height + num_blocks == cur_height); +} + pub(crate) fn wait_for_block(electrs: &E, min_height: usize) { let mut header = match electrs.block_headers_subscribe() { Ok(header) => header, @@ -474,18 +491,27 @@ pub(crate) fn premine_and_distribute_funds( let _ = bitcoind.load_wallet("ldk_node_test"); generate_blocks_and_wait(bitcoind, electrs, 101); - for addr in addrs { - let txid = bitcoind.send_to_address(&addr, amount).unwrap().0.parse().unwrap(); - wait_for_tx(electrs, txid); - } + let amounts: HashMap = + addrs.iter().map(|addr| (addr.to_string(), amount.to_btc())).collect(); + + let empty_account = json!(""); + let amounts_json = json!(amounts); + let txid = bitcoind + .call::("sendmany", &[empty_account, amounts_json]) + .unwrap() + .as_str() + .unwrap() + .parse() + .unwrap(); + wait_for_tx(electrs, txid); generate_blocks_and_wait(bitcoind, electrs, 1); } pub fn open_channel( node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, should_announce: bool, electrsd: &ElectrsD, -) { +) -> OutPoint { if should_announce { node_a .open_announced_channel( @@ -513,6 +539,8 @@ pub fn open_channel( let funding_txo_b = expect_channel_pending_event!(node_b, node_a.node_id()); assert_eq!(funding_txo_a, funding_txo_b); wait_for_tx(&electrsd.client, funding_txo_a.txid); + + funding_txo_a } pub(crate) fn do_channel_full_cycle( diff --git a/tests/reorg_test.rs b/tests/reorg_test.rs new file mode 100644 index 000000000..707b67e88 --- /dev/null +++ b/tests/reorg_test.rs @@ -0,0 +1,193 @@ +mod common; +use bitcoin::Amount; +use ldk_node::payment::{PaymentDirection, PaymentKind}; +use ldk_node::{Event, LightningBalance, PendingSweepBalance}; +use proptest::{prelude::prop, proptest}; +use std::collections::HashMap; + +use crate::common::{ + expect_event, generate_blocks_and_wait, invalidate_blocks, open_channel, + premine_and_distribute_funds, random_config, setup_bitcoind_and_electrsd, setup_node, + wait_for_outpoint_spend, TestChainSource, +}; + +proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(5))] + #[test] + fn reorg_test(reorg_depth in 1..=6usize, force_close in prop::bool::ANY) { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + + let chain_source_bitcoind = TestChainSource::BitcoindRpcSync(&bitcoind); + let chain_source_electrsd = TestChainSource::Electrum(&electrsd); + let chain_source_esplora = TestChainSource::Esplora(&electrsd); + + macro_rules! config_node { + ($chain_source: expr, $anchor_channels: expr) => {{ + let config_a = random_config($anchor_channels); + let node = setup_node(&$chain_source, config_a, None); + node + }}; + } + let anchor_channels = true; + let nodes = vec![ + config_node!(chain_source_electrsd, anchor_channels), + config_node!(chain_source_bitcoind, anchor_channels), + config_node!(chain_source_esplora, anchor_channels), + ]; + + let (bitcoind, electrs) = (&bitcoind.client, &electrsd.client); + macro_rules! reorg { + ($reorg_depth: expr) => {{ + invalidate_blocks(bitcoind, $reorg_depth); + generate_blocks_and_wait(bitcoind, electrs, $reorg_depth); + }}; + } + + let amount_sat = 2_100_000; + let addr_nodes = + nodes.iter().map(|node| node.onchain_payment().new_address().unwrap()).collect::>(); + premine_and_distribute_funds(bitcoind, electrs, addr_nodes, Amount::from_sat(amount_sat)); + + macro_rules! sync_wallets { + () => { + nodes.iter().for_each(|node| node.sync_wallets().unwrap()) + }; + } + sync_wallets!(); + nodes.iter().for_each(|node| { + assert_eq!(node.list_balances().spendable_onchain_balance_sats, amount_sat); + assert_eq!(node.list_balances().total_onchain_balance_sats, amount_sat); + }); + + + let mut nodes_funding_tx = HashMap::new(); + let funding_amount_sat = 2_000_000; + for (node, next_node) in nodes.iter().zip(nodes.iter().cycle().skip(1)) { + let funding_txo = open_channel(node, next_node, funding_amount_sat, true, &electrsd); + nodes_funding_tx.insert(node.node_id(), funding_txo); + } + + generate_blocks_and_wait(bitcoind, electrs, 6); + sync_wallets!(); + + reorg!(reorg_depth); + sync_wallets!(); + + macro_rules! collect_channel_ready_events { + ($node:expr, $expected:expr) => {{ + let mut user_channels = HashMap::new(); + for _ in 0..$expected { + match $node.wait_next_event() { + Event::ChannelReady { user_channel_id, counterparty_node_id, .. } => { + $node.event_handled().unwrap(); + user_channels.insert(counterparty_node_id, user_channel_id); + }, + other => panic!("Unexpected event: {:?}", other), + } + } + user_channels + }}; + } + + let mut node_channels_id = HashMap::new(); + for (i, node) in nodes.iter().enumerate() { + assert_eq!( + node + .list_payments_with_filter(|p| p.direction == PaymentDirection::Outbound + && matches!(p.kind, PaymentKind::Onchain { .. })) + .len(), + 1 + ); + + let user_channels = collect_channel_ready_events!(node, 2); + let next_node = nodes.get((i + 1) % nodes.len()).unwrap(); + let prev_node = nodes.get((i + nodes.len() - 1) % nodes.len()).unwrap(); + + assert!(user_channels.get(&Some(next_node.node_id())) != None); + assert!(user_channels.get(&Some(prev_node.node_id())) != None); + + let user_channel_id = + user_channels.get(&Some(next_node.node_id())).expect("Missing user channel for node"); + node_channels_id.insert(node.node_id(), *user_channel_id); + } + + + for (node, next_node) in nodes.iter().zip(nodes.iter().cycle().skip(1)) { + let user_channel_id = node_channels_id.get(&node.node_id()).expect("user channel id not exist"); + let funding = nodes_funding_tx.get(&node.node_id()).expect("Funding tx not exist"); + + if force_close { + node.force_close_channel(&user_channel_id, next_node.node_id(), None).unwrap(); + } else { + node.close_channel(&user_channel_id, next_node.node_id()).unwrap(); + } + + expect_event!(node, ChannelClosed); + expect_event!(next_node, ChannelClosed); + + wait_for_outpoint_spend(electrs, *funding); + } + + reorg!(reorg_depth); + sync_wallets!(); + + generate_blocks_and_wait(bitcoind, electrs, 1); + sync_wallets!(); + + if force_close { + nodes.iter().for_each(|node| { + node.sync_wallets().unwrap(); + // If there is no more balance, there is nothing to process here. + if node.list_balances().lightning_balances.len() < 1 { + return; + } + match node.list_balances().lightning_balances[0] { + LightningBalance::ClaimableAwaitingConfirmations { + confirmation_height, + .. + } => { + let cur_height = node.status().current_best_block.height; + let blocks_to_go = confirmation_height - cur_height; + generate_blocks_and_wait(bitcoind, electrs, blocks_to_go as usize); + node.sync_wallets().unwrap(); + }, + _ => panic!("Unexpected balance state for node_hub!"), + } + + assert!(node.list_balances().lightning_balances.len() < 2); + assert!(node.list_balances().pending_balances_from_channel_closures.len() > 0); + match node.list_balances().pending_balances_from_channel_closures[0] { + PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => {}, + _ => panic!("Unexpected balance state!"), + } + + generate_blocks_and_wait(&bitcoind, electrs, 1); + node.sync_wallets().unwrap(); + assert!(node.list_balances().lightning_balances.len() < 2); + assert!(node.list_balances().pending_balances_from_channel_closures.len() > 0); + match node.list_balances().pending_balances_from_channel_closures[0] { + PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, + _ => panic!("Unexpected balance state!"), + } + }); + } + + generate_blocks_and_wait(bitcoind, electrs, 6); + sync_wallets!(); + + reorg!(reorg_depth); + sync_wallets!(); + + let fee_sat = 7000; + // Check balance after close channel + nodes.iter().for_each(|node| { + assert!(node.list_balances().spendable_onchain_balance_sats > amount_sat - fee_sat); + assert!(node.list_balances().spendable_onchain_balance_sats < amount_sat); + + assert_eq!(node.list_balances().total_anchor_channels_reserve_sats, 0); + assert!(node.list_balances().lightning_balances.is_empty()); + + assert_eq!(node.next_event(), None); + }); + } +}