Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bindings/ldk_node.udl
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ interface Node {
[Throws=NodeError]
void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, [ByRef]Address address, u64 splice_amount_sats);
[Throws=NodeError]
void rbf_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id);
[Throws=NodeError]
void close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id);
[Throws=NodeError]
void force_close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, string? reason);
Expand Down
79 changes: 79 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,14 @@ impl Node {
Error::ChannelSplicingFailed
})?;

if funding_template.min_rbf_feerate().is_some() {
log_error!(
self.logger,
"Failed to splice channel: pending splice requires RBF, use rbf_channel instead"
);
return Err(Error::ChannelSplicingFailed);
}

let contribution = self
.runtime
.block_on(funding_template.splice_in(
Expand Down Expand Up @@ -1694,6 +1702,14 @@ impl Node {
Error::ChannelSplicingFailed
})?;

if funding_template.min_rbf_feerate().is_some() {
log_error!(
self.logger,
"Failed to splice channel: pending splice requires RBF, use rbf_channel instead"
);
return Err(Error::ChannelSplicingFailed);
}

let outputs = vec![bitcoin::TxOut {
value: Amount::from_sat(splice_amount_sats),
script_pubkey: address.script_pubkey(),
Expand Down Expand Up @@ -1733,6 +1749,69 @@ impl Node {
}
}

/// Replace a pending splice's funding transaction with a higher-feerate version.
///
/// If a prior splice negotiation is pending, this bumps its feerate via RBF. The prior
/// contribution is reused when possible; otherwise, coin selection is re-run.
///
/// # Experimental API
///
/// This API is experimental and may change in the future.
pub fn rbf_channel(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Maybe this could be called replace_channel_funding or bump_channel_funding_fee or similar?

&self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey,
) -> Result<(), Error> {
let open_channels =
self.channel_manager.list_channels_with_counterparty(&counterparty_node_id);
if let Some(channel_details) =
open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0)
{
let min_feerate =
self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding);
let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2);

let funding_template = self
.channel_manager
.splice_channel(&channel_details.channel_id, &counterparty_node_id)
.map_err(|e| {
log_error!(self.logger, "Failed to RBF channel: {:?}", e);
Error::ChannelSplicingFailed
})?;

if funding_template.min_rbf_feerate().is_none() {
log_error!(self.logger, "Failed to RBF channel: no pending splice to replace");
return Err(Error::ChannelSplicingFailed);
}

let contribution = self
.runtime
.block_on(funding_template.rbf(max_feerate, Arc::clone(&self.wallet)))
.map_err(|e| {
log_error!(self.logger, "Failed to RBF channel: {}", e);
Error::ChannelSplicingFailed
})?;

self.channel_manager
.funding_contributed(
&channel_details.channel_id,
&counterparty_node_id,
contribution,
None,
)
.map_err(|e| {
log_error!(self.logger, "Failed to RBF channel: {:?}", e);
Error::ChannelSplicingFailed
})
} else {
log_error!(
self.logger,
"Channel not found for user_channel_id {} and counterparty {}",
user_channel_id,
counterparty_node_id
);
Err(Error::ChannelSplicingFailed)
}
}

/// Manually sync the LDK and BDK wallets with the current chain state and update the fee rate
/// cache.
///
Expand Down
8 changes: 6 additions & 2 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
// accordance with one or both of these licenses.

use std::collections::HashMap;
use std::future::Future;
use std::ops::Deref;
use std::str::FromStr;
Expand Down Expand Up @@ -1084,9 +1085,12 @@ impl Wallet {
let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).map_err(|e| {
log_error!(self.logger, "Failed to construct PSBT: {}", e);
})?;
// Use list_output rather than get_utxo to include outputs spent by unconfirmed
// transactions (e.g., a prior splice being replaced via RBF).
let mut wallet_outputs: HashMap<bitcoin::OutPoint, bdk_wallet::LocalOutput> =
locked_wallet.list_output().map(|o| (o.outpoint, o)).collect();
for (i, txin) in psbt.unsigned_tx.input.iter().enumerate() {
if let Some(utxo) = locked_wallet.get_utxo(txin.previous_output) {
debug_assert!(!utxo.is_spent);
if let Some(utxo) = wallet_outputs.remove(&txin.previous_output) {
psbt.inputs[i] = locked_wallet.get_psbt_input(utxo, None, true).map_err(|e| {
log_error!(self.logger, "Failed to construct PSBT input: {}", e);
})?;
Expand Down
118 changes: 117 additions & 1 deletion tests/integration_tests_rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use common::{
setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all,
wait_for_tx, TestChainSource, TestStoreType, TestSyncStore,
};
use electrsd::corepc_node::Node as BitcoinD;
use electrsd::corepc_node::{self, Node as BitcoinD};
use electrsd::ElectrsD;
use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig};
use ldk_node::entropy::NodeEntropy;
Expand Down Expand Up @@ -1127,6 +1127,122 @@ async fn splice_channel() {
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn rbf_splice_channel() {
// Use a custom bitcoind config with a lower incrementalrelayfee so that the +25 sat/kwu
// (0.1 sat/vB) RBF feerate bump satisfies BIP125's absolute fee increase requirement.
let bitcoind_exe = std::env::var("BITCOIND_EXE")
.ok()
.or_else(|| corepc_node::downloaded_exe_path().ok())
.expect(
"you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature",
);
let mut bitcoind_conf = corepc_node::Conf::default();
bitcoind_conf.network = "regtest";
bitcoind_conf.args.push("-rest");
bitcoind_conf.args.push("-incrementalrelayfee=0.00000100");
let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap();

let electrs_exe = std::env::var("ELECTRS_EXE")
.ok()
.or_else(electrsd::downloaded_exe_path)
.expect("you need to provide env var ELECTRS_EXE or specify an electrsd version feature");
let mut electrsd_conf = electrsd::Conf::default();
electrsd_conf.http_enabled = true;
electrsd_conf.network = "regtest";
let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap();
let chain_source = random_chain_source(&bitcoind, &electrsd);

let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false);

let address_a = node_a.onchain_payment().new_address().unwrap();
let address_b = node_b.onchain_payment().new_address().unwrap();
let premine_amount_sat = 5_000_000;
premine_and_distribute_funds(
&bitcoind.client,
&electrsd.client,
vec![address_a, address_b],
Amount::from_sat(premine_amount_sat),
)
.await;

node_a.sync_wallets().unwrap();
node_b.sync_wallets().unwrap();

open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await;

generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;

node_a.sync_wallets().unwrap();
node_b.sync_wallets().unwrap();

let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id());
let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id());

// rbf_channel should fail when there's no pending splice
assert_eq!(
node_b.rbf_channel(&user_channel_id_b, node_a.node_id()),
Err(NodeError::ChannelSplicingFailed),
);

// Initiate a splice-in to create a pending splice
node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap();

let original_txo = expect_splice_pending_event!(node_a, node_b.node_id());
expect_splice_pending_event!(node_b, node_a.node_id());

// splice_in should fail when there's a pending splice (RBF guard)
assert_eq!(
node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000),
Err(NodeError::ChannelSplicingFailed),
);

// splice_out should fail when there's a pending splice (RBF guard)
let address = node_a.onchain_payment().new_address().unwrap();
assert_eq!(
node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 100_000),
Err(NodeError::ChannelSplicingFailed),
);

// rbf_channel should succeed when there's a pending splice
node_b.rbf_channel(&user_channel_id_b, node_a.node_id()).unwrap();

let rbf_txo = expect_splice_pending_event!(node_a, node_b.node_id());
expect_splice_pending_event!(node_b, node_a.node_id());

assert_ne!(original_txo, rbf_txo, "RBF should produce a different funding txo");

// Wait for the RBF transaction to replace the original in the mempool
wait_for_tx(&electrsd.client, rbf_txo.txid).await;

// Mine blocks and confirm the RBF splice
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;

node_a.sync_wallets().unwrap();
node_b.sync_wallets().unwrap();

// Verify the RBF transaction is the one that locked, not the original
match node_a.next_event_async().await {
Event::ChannelReady { funding_txo, counterparty_node_id, .. } => {
assert_eq!(counterparty_node_id, Some(node_b.node_id()));
assert_eq!(funding_txo, Some(rbf_txo));
node_a.event_handled().unwrap();
},
ref e => panic!("node_a got unexpected event: {:?}", e),
}
match node_b.next_event_async().await {
Event::ChannelReady { funding_txo, counterparty_node_id, .. } => {
assert_eq!(counterparty_node_id, Some(node_a.node_id()));
assert_eq!(funding_txo, Some(rbf_txo));
node_b.event_handled().unwrap();
},
ref e => panic!("node_b got unexpected event: {:?}", e),
}

node_a.stop().unwrap();
node_b.stop().unwrap();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn simple_bolt12_send_receive() {
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
Expand Down