Skip to content

Commit 5961e27

Browse files
Add RBF integration tests with multi-node setup
- Test mempool-only RBF handling and balance adjustments. - Test RBF transactions confirmed in block, ensuring stale unconfirmed txs are removed. - Introduce `distribute_funds_unconfirmed` for creating unconfirmed outputs. - Add `MultiNodeTestSetup` helper for wallet sync, balance checks, fund distribution, and RBF preparation.
1 parent 9292d90 commit 5961e27

File tree

2 files changed

+303
-6
lines changed

2 files changed

+303
-6
lines changed

tests/common/mod.rs

Lines changed: 205 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@ use lightning_types::payment::{PaymentHash, PaymentPreimage};
2929

3030
use lightning_persister::fs_store::FilesystemStore;
3131

32+
use bitcoin::hashes::hex::FromHex;
3233
use bitcoin::hashes::sha256::Hash as Sha256;
3334
use bitcoin::hashes::Hash;
34-
use bitcoin::{Address, Amount, Network, OutPoint, Txid};
35+
use bitcoin::{
36+
Address, Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction, Txid, Witness,
37+
};
3538

3639
use electrsd::corepc_node::Client as BitcoindClient;
3740
use electrsd::corepc_node::Node as BitcoinD;
@@ -40,7 +43,9 @@ use electrum_client::ElectrumApi;
4043

4144
use rand::distributions::Alphanumeric;
4245
use rand::{thread_rng, Rng};
46+
use serde_json::{json, Value};
4347

48+
use std::collections::{HashMap, HashSet};
4449
use std::env;
4550
use std::path::PathBuf;
4651
use std::sync::{Arc, RwLock};
@@ -470,16 +475,47 @@ where
470475
pub(crate) fn premine_and_distribute_funds<E: ElectrumApi>(
471476
bitcoind: &BitcoindClient, electrs: &E, addrs: Vec<Address>, amount: Amount,
472477
) {
478+
premine_blocks(bitcoind, electrs);
479+
480+
distribute_funds(bitcoind, electrs, addrs, amount);
481+
}
482+
483+
pub(crate) fn premine_blocks<E: ElectrumApi>(bitcoind: &BitcoindClient, electrs: &E) {
473484
let _ = bitcoind.create_wallet("ldk_node_test");
474485
let _ = bitcoind.load_wallet("ldk_node_test");
475486
generate_blocks_and_wait(bitcoind, electrs, 101);
487+
}
476488

477-
for addr in addrs {
478-
let txid = bitcoind.send_to_address(&addr, amount).unwrap().0.parse().unwrap();
479-
wait_for_tx(electrs, txid);
489+
pub(crate) fn distribute_funds<E: ElectrumApi>(
490+
bitcoind: &BitcoindClient, electrs: &E, addrs: Vec<Address>, amount: Amount,
491+
) -> Txid {
492+
let address_txid_map = distribute_funds_unconfirmed(bitcoind, electrs, addrs, amount);
493+
generate_blocks_and_wait(bitcoind, electrs, 1);
494+
495+
address_txid_map
496+
}
497+
498+
pub(crate) fn distribute_funds_unconfirmed<E: ElectrumApi>(
499+
bitcoind: &BitcoindClient, electrs: &E, addrs: Vec<Address>, amount: Amount,
500+
) -> Txid {
501+
let mut amounts = HashMap::<String, f64>::new();
502+
for addr in &addrs {
503+
amounts.insert(addr.to_string(), amount.to_btc());
480504
}
481505

482-
generate_blocks_and_wait(bitcoind, electrs, 1);
506+
let empty_account = json!("");
507+
let amounts_json = json!(amounts);
508+
let txid = bitcoind
509+
.call::<Value>("sendmany", &[empty_account, amounts_json])
510+
.unwrap()
511+
.as_str()
512+
.unwrap()
513+
.parse()
514+
.unwrap();
515+
516+
wait_for_tx(electrs, txid);
517+
518+
txid
483519
}
484520

485521
pub fn open_channel(
@@ -1074,6 +1110,170 @@ pub(crate) fn do_channel_full_cycle<E: ElectrumApi>(
10741110
println!("\nB stopped");
10751111
}
10761112

1113+
pub(crate) struct MultiNodeTestSetup {
1114+
pub nodes_a: Vec<TestNode>,
1115+
pub nodes_b: Vec<TestNode>,
1116+
pub addrs_a: Vec<Address>,
1117+
pub addrs_b: Vec<Address>,
1118+
}
1119+
1120+
impl MultiNodeTestSetup {
1121+
pub(crate) fn new(bitcoind: &BitcoinD, electrsd: &ElectrsD) -> Self {
1122+
let chain_source_bitcoind = TestChainSource::BitcoindRpcSync(bitcoind);
1123+
let chain_source_electrum = TestChainSource::Electrum(electrsd);
1124+
let chain_source_esplora = TestChainSource::Esplora(electrsd);
1125+
1126+
let (node_bitcoind_a, node_bitcoind_b) =
1127+
setup_two_nodes(&chain_source_bitcoind, false, false, false);
1128+
let (node_electrsd_a, node_electrsd_b) =
1129+
setup_two_nodes(&chain_source_electrum, false, false, false);
1130+
let (node_esplora_a, node_esplora_b) =
1131+
setup_two_nodes(&chain_source_esplora, false, false, false);
1132+
1133+
let nodes_a = vec![node_bitcoind_a, node_electrsd_a, node_esplora_a];
1134+
let nodes_b = vec![node_bitcoind_b, node_electrsd_b, node_esplora_b];
1135+
1136+
let addrs_a = nodes_a
1137+
.iter()
1138+
.map(|node| node.onchain_payment().new_address().unwrap())
1139+
.collect::<Vec<_>>();
1140+
let addrs_b = nodes_b
1141+
.iter()
1142+
.map(|node| node.onchain_payment().new_address().unwrap())
1143+
.collect::<Vec<_>>();
1144+
1145+
Self { nodes_a, nodes_b, addrs_a, addrs_b }
1146+
}
1147+
1148+
pub(crate) fn sync_wallets(&self) {
1149+
for node in &self.nodes_a {
1150+
node.sync_wallets().unwrap();
1151+
}
1152+
for node in &self.nodes_b {
1153+
node.sync_wallets().unwrap();
1154+
}
1155+
}
1156+
1157+
pub(crate) fn validate_balances(
1158+
&self, nodes: &[TestNode], expected_balance: u64, is_spendable: bool,
1159+
) {
1160+
let spend_balance = if is_spendable { expected_balance } else { 0 };
1161+
for node in nodes.iter() {
1162+
assert_eq!(node.list_balances().total_onchain_balance_sats, expected_balance);
1163+
assert_eq!(node.list_balances().spendable_onchain_balance_sats, spend_balance);
1164+
}
1165+
}
1166+
1167+
pub(crate) fn setup_initial_funding<E: ElectrumApi>(
1168+
&self, bitcoind: &BitcoindClient, electrs: &E, amount: u64,
1169+
) -> bitcoin::Txid {
1170+
premine_blocks(bitcoind, electrs);
1171+
self.distribute_funds(bitcoind, electrs, amount)
1172+
}
1173+
1174+
pub(crate) fn distribute_funds<E: ElectrumApi>(
1175+
&self, bitcoind: &BitcoindClient, electrs: &E, amount: u64,
1176+
) -> bitcoin::Txid {
1177+
let all_addrs = self.addrs_a.iter().chain(self.addrs_b.iter()).cloned().collect::<Vec<_>>();
1178+
distribute_funds_unconfirmed(bitcoind, electrs, all_addrs, Amount::from_sat(amount))
1179+
}
1180+
1181+
pub(crate) fn bump_fee_rbf<E: ElectrumApi>(
1182+
&self, bitcoind: &BitcoindClient, electrs: &E, original_tx: &mut Transaction,
1183+
fee_output_index: usize, is_insert_block: bool,
1184+
) -> Txid {
1185+
let mut bump_fee_amount = original_tx.vsize() as u64;
1186+
1187+
macro_rules! bump_fee {
1188+
() => {{
1189+
let fee_output = &mut original_tx.output[fee_output_index];
1190+
let new_fee_value = fee_output.value.to_sat().saturating_sub(bump_fee_amount);
1191+
fee_output.value = Amount::from_sat(new_fee_value);
1192+
1193+
// dust limit
1194+
if new_fee_value < 546 {
1195+
panic!("Warning: Fee output approaching dust limit ({} sats)", new_fee_value);
1196+
}
1197+
1198+
for input in &mut original_tx.input {
1199+
input.sequence = Sequence::ENABLE_RBF_NO_LOCKTIME;
1200+
input.script_sig = ScriptBuf::new();
1201+
input.witness = Witness::new();
1202+
}
1203+
1204+
let signed_result =
1205+
bitcoind.sign_raw_transaction_with_wallet(&original_tx).unwrap();
1206+
assert!(signed_result.complete, "Failed to sign RBF transaction");
1207+
1208+
let tx_bytes = Vec::<u8>::from_hex(&signed_result.hex).unwrap();
1209+
let tx = bitcoin::consensus::encode::deserialize::<Transaction>(&tx_bytes).unwrap();
1210+
1211+
tx
1212+
}};
1213+
}
1214+
let attempts = 3;
1215+
for _attempt in 0..attempts {
1216+
let tx = bump_fee!();
1217+
match bitcoind.send_raw_transaction(&tx) {
1218+
Ok(res) => {
1219+
// Mine a block immediately so the transaction is confirmed
1220+
// before any node identifies it as a transaction that was in the mempool.
1221+
if is_insert_block {
1222+
generate_blocks_and_wait(bitcoind, electrs, 1);
1223+
}
1224+
let new_txid = res.0.parse().unwrap();
1225+
wait_for_tx(electrs, new_txid);
1226+
return new_txid;
1227+
},
1228+
Err(_) => {
1229+
bump_fee_amount += bump_fee_amount * 5;
1230+
if original_tx.output[fee_output_index].value.to_sat() < bump_fee_amount {
1231+
panic!("Insufficient funds to increase fee");
1232+
}
1233+
},
1234+
}
1235+
}
1236+
1237+
panic!("Failed to pump fee after {} attempts", attempts);
1238+
}
1239+
1240+
pub(crate) fn prepare_rbf<E: ElectrumApi>(
1241+
&self, electrs: &E, original_txid: Txid,
1242+
) -> (Transaction, HashSet<ScriptBuf>, HashSet<ScriptBuf>, usize) {
1243+
let original_tx: Transaction = electrs.transaction_get(&original_txid).unwrap();
1244+
1245+
let total_addresses_to_modify = &self.addrs_a.len() + &self.addrs_b.len();
1246+
if original_tx.output.len() <= total_addresses_to_modify {
1247+
panic!(
1248+
"Transaction must have more outputs ({}) than addresses to modify ({}) to allow fee pumping",
1249+
original_tx.output.len(),
1250+
total_addresses_to_modify
1251+
);
1252+
}
1253+
1254+
let scripts_a: HashSet<ScriptBuf> =
1255+
self.addrs_a.iter().map(|addr| addr.script_pubkey()).collect();
1256+
let scripts_b: HashSet<ScriptBuf> =
1257+
self.addrs_b.iter().map(|addr| addr.script_pubkey()).collect();
1258+
1259+
let mut fee_output_index: Option<usize> = None;
1260+
for (index, output) in original_tx.output.iter().enumerate() {
1261+
if !scripts_a.contains(&output.script_pubkey)
1262+
&& !scripts_b.contains(&output.script_pubkey)
1263+
{
1264+
fee_output_index = Some(index);
1265+
break;
1266+
}
1267+
}
1268+
1269+
let fee_output_index = fee_output_index.expect(
1270+
"No output available for fee pumping. Need at least one output not being modified.",
1271+
);
1272+
1273+
(original_tx, scripts_a, scripts_b, fee_output_index)
1274+
}
1275+
}
1276+
10771277
// A `KVStore` impl for testing purposes that wraps all our `KVStore`s and asserts their synchronicity.
10781278
pub(crate) struct TestSyncStore {
10791279
serializer: RwLock<()>,

tests/integration_tests_rust.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use common::{
1313
logging::{init_log_logger, validate_log_entry, TestLogWriter},
1414
open_channel, premine_and_distribute_funds, random_config, random_listening_addresses,
1515
setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, wait_for_tx,
16-
TestChainSource, TestSyncStore,
16+
MultiNodeTestSetup, TestChainSource, TestSyncStore,
1717
};
1818

1919
use ldk_node::config::EsploraSyncConfig;
@@ -669,6 +669,103 @@ fn onchain_wallet_recovery() {
669669
);
670670
}
671671

672+
#[test]
673+
fn test_rbf_via_mempool() {
674+
run_rbf_test(false);
675+
}
676+
677+
#[test]
678+
fn test_rbf_via_direct_block_insertion() {
679+
run_rbf_test(true);
680+
}
681+
682+
/// Generic RBF (Replace-By-Fee) test.
683+
///
684+
/// `is_insert_block` defines how the transaction is introduced:
685+
/// - `true`: the transaction is mined into a block immediately, bypassing the mempool.
686+
/// This simulates a node receiving only confirmed transactions (no mempool contact),
687+
/// testing its ability to detect and handle already-confirmed transactions.
688+
/// - `false`: the transaction stays in the mempool until confirmed normally.
689+
/// This tests the node’s ability to detect and handle unconfirmed transactions in its mempool.
690+
fn run_rbf_test(is_insert_block: bool) {
691+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
692+
let setup = MultiNodeTestSetup::new(&bitcoind, &electrsd);
693+
let amount = 2_125_000;
694+
695+
let (bitcoind, electrs) = (&bitcoind.client, &electrsd.client);
696+
let txid = setup.setup_initial_funding(bitcoind, electrs, amount);
697+
698+
setup.sync_wallets();
699+
setup.validate_balances(&setup.nodes_a, amount, false);
700+
setup.validate_balances(&setup.nodes_b, amount, false);
701+
702+
let (mut original_tx, _, modify_scripts, fee_output_index) = setup.prepare_rbf(electrs, txid);
703+
original_tx.output.iter_mut().for_each(|output| {
704+
if modify_scripts.contains(&output.script_pubkey) {
705+
let new_addr = bitcoind.new_address().unwrap();
706+
output.script_pubkey = new_addr.script_pubkey();
707+
}
708+
});
709+
710+
setup.bump_fee_rbf(bitcoind, electrs, &mut original_tx, fee_output_index, is_insert_block);
711+
712+
let is_spendable = is_insert_block;
713+
setup.sync_wallets();
714+
setup.validate_balances(&setup.nodes_a, amount, is_spendable);
715+
setup.validate_balances(&setup.nodes_b, 0, is_spendable);
716+
717+
let txid = setup.distribute_funds(bitcoind, electrs, amount);
718+
let mut amount_a = amount * 2;
719+
let mut amount_b = amount;
720+
if !is_insert_block {
721+
setup.sync_wallets();
722+
setup.validate_balances(&setup.nodes_a, amount_a, is_spendable);
723+
setup.validate_balances(&setup.nodes_b, amount_b, is_spendable);
724+
}
725+
726+
let (mut original_tx, add_scripts, subtract_scripts, fee_output_index) =
727+
setup.prepare_rbf(electrs, txid);
728+
729+
let value = 21_000;
730+
original_tx.output.iter_mut().for_each(|output| {
731+
if add_scripts.contains(&output.script_pubkey) {
732+
output.value = Amount::from_sat(output.value.to_sat() + value);
733+
} else if subtract_scripts.contains(&output.script_pubkey) {
734+
output.value = Amount::from_sat(output.value.to_sat().saturating_sub(value));
735+
}
736+
});
737+
amount_a += value;
738+
amount_b -= value;
739+
740+
setup.bump_fee_rbf(bitcoind, electrs, &mut original_tx, fee_output_index, is_insert_block);
741+
742+
setup.sync_wallets();
743+
setup.validate_balances(&setup.nodes_a, amount_a, is_spendable);
744+
setup.validate_balances(&setup.nodes_b, amount_b, is_spendable);
745+
746+
if !is_insert_block {
747+
generate_blocks_and_wait(bitcoind, electrs, 1);
748+
setup.sync_wallets();
749+
setup.validate_balances(&setup.nodes_a, amount_a, true);
750+
setup.validate_balances(&setup.nodes_b, amount_b, true);
751+
}
752+
753+
let nodes = setup.nodes_a.iter().chain(setup.nodes_b.iter()).collect::<Vec<_>>();
754+
let mut txids = Vec::new();
755+
let addr = bitcoind.new_address().unwrap();
756+
nodes.iter().for_each(|node| {
757+
let txid = node.onchain_payment().send_all_to_address(&addr, true, None).unwrap();
758+
txids.push(txid);
759+
});
760+
txids.iter().for_each(|txid| {
761+
wait_for_tx(electrs, *txid);
762+
});
763+
generate_blocks_and_wait(bitcoind, electrs, 6);
764+
setup.sync_wallets();
765+
setup.validate_balances(&setup.nodes_a, 0, true);
766+
setup.validate_balances(&setup.nodes_b, 0, true);
767+
}
768+
672769
#[test]
673770
fn sign_verify_msg() {
674771
let (_bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)