From b91d0186b4873988a239fbe48f489a82fcf2ce50 Mon Sep 17 00:00:00 2001 From: Shubham Kumar Date: Mon, 12 Oct 2026 19:22:32 +0530 Subject: [PATCH 1/3] Allow specifying a fee rate when splicing `Node::splice_in`, `Node::splice_in_with_all`, and `Node::splice_out` always contributed at our `ChannelFunding` feerate estimate, leaving no way to choose the feerate of a splice, unlike for on-chain sends. Add an optional `min_fee_rate` to all three. If set, it takes the place of the estimate as the minimum feerate of our contribution: the existing logic deriving the maximum feerate and meeting a pending splice's RBF minimum applies to it unchanged, and `splice_in_with_all` determines the amount to splice in at it. If unset, behavior is unchanged. Developed with assistance from Claude Code. --- CHANGELOG.md | 5 ++++ bindings/ldk_node.udl | 6 ++--- src/builder.rs | 2 +- src/lib.rs | 47 ++++++++++++++++++++++++++------ tests/common/mod.rs | 6 ++--- tests/common/scenarios/mod.rs | 2 +- tests/integration_tests_rust.rs | 48 ++++++++++++++++++--------------- 7 files changed, 79 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfdc5701a2..5c4a5ab0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,11 @@ disclosed. The method is stateless: the payment id, preimage, and invoice are taken from `Event::PaymentSuccessful` and nothing is persisted. Payments settled via a static invoice, i.e., async payments, don't support payer proofs. (#1045) +- `Node::splice_in`, `Node::splice_in_with_all`, and `Node::splice_out` now take an optional + `min_fee_rate`. If set, it is used as the minimum feerate for our contribution to the splice + transaction instead of a feerate estimate retrieved from the configured chain source. For + `splice_in_with_all`, the amount spliced in is determined at `min_fee_rate`, or at the estimate + if unset. ## Bug Fixes and Improvements - Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 259ae41d55..960aa8c211 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -90,11 +90,11 @@ interface Node { [Throws=NodeError] UserChannelId open_0reserve_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] - void splice_in([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, u64 splice_amount_sats); + void splice_in([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, u64 splice_amount_sats, FeeRate? min_fee_rate); [Throws=NodeError] - void splice_in_with_all([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); + void splice_in_with_all([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, FeeRate? min_fee_rate); [Throws=NodeError] - void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, [ByRef]Address address, u64 splice_amount_sats); + void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, [ByRef]Address address, u64 splice_amount_sats, FeeRate? min_fee_rate); [Throws=NodeError] void bump_channel_funding_fee([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); [Throws=NodeError] diff --git a/src/builder.rs b/src/builder.rs index 7f28996695..4ca80d4f6b 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2819,7 +2819,7 @@ mod tests { node.update_channel_config(&user_channel_id, node_id, Default::default()), Err(Error::NotRunning) ); - assert_eq!(node.splice_in(&user_channel_id, node_id, 1_000), Err(Error::NotRunning)); + assert_eq!(node.splice_in(&user_channel_id, node_id, 1_000, None), Err(Error::NotRunning)); assert_eq!( node.bump_channel_funding_fee(&user_channel_id, node_id), Err(Error::NotRunning) diff --git a/src/lib.rs b/src/lib.rs index 7262e9ba02..18f5da48b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1805,15 +1805,16 @@ impl Node { fn splice_in_inner( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, - splice_amount_sats: FundingAmount, + splice_amount_sats: FundingAmount, min_fee_rate: Option, ) -> 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 min_feerate = min_fee_rate.unwrap_or_else(|| { + self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding) + }); let max_feerate = max_funding_feerate(min_feerate); let splice_amount_sats = match splice_amount_sats { @@ -1939,13 +1940,19 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// If `min_fee_rate` is set, it will be used as the minimum feerate for our contribution to the + /// splice transaction instead of a feerate estimate retrieved from the configured chain source. + /// As with the estimate, we may pay up to 1.5 times this feerate: when the channel already has a + /// pending splice that requires a higher feerate to be replaced, or when the counterparty + /// initiates a splice concurrently and our contribution is added to theirs. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but /// this classification may change in the future. pub fn splice_in( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, - splice_amount_sats: u64, + splice_amount_sats: u64, min_fee_rate: Option, ) -> Result<(), Error> { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); @@ -1955,6 +1962,7 @@ impl Node { user_channel_id, counterparty_node_id, FundingAmount::Exact { amount_sats: splice_amount_sats }, + maybe_map_fee_rate_opt(min_fee_rate), ) } @@ -1967,17 +1975,31 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// If `min_fee_rate` is set, it will be used as the minimum feerate for our contribution to the + /// splice transaction instead of a feerate estimate retrieved from the configured chain source. + /// As with the estimate, we may pay up to 1.5 times this feerate: when the channel already has a + /// pending splice that requires a higher feerate to be replaced, or when the counterparty + /// initiates a splice concurrently and our contribution is added to theirs. + /// + /// The amount spliced in is determined at `min_fee_rate`, or at the estimate if unset. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but /// this classification may change in the future. pub fn splice_in_with_all( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, + min_fee_rate: Option, ) -> Result<(), Error> { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } - self.splice_in_inner(user_channel_id, counterparty_node_id, FundingAmount::Max) + self.splice_in_inner( + user_channel_id, + counterparty_node_id, + FundingAmount::Max, + maybe_map_fee_rate_opt(min_fee_rate), + ) } /// Remove funds from an existing channel, sending them to an on-chain address. @@ -1986,6 +2008,12 @@ impl Node { /// it. Once negotiation with the counterparty is complete, the channel remains operational /// while waiting for a new funding transaction to confirm. /// + /// If `min_fee_rate` is set, it will be used as the minimum feerate for our contribution to the + /// splice transaction instead of a feerate estimate retrieved from the configured chain source. + /// As with the estimate, we may pay up to 1.5 times this feerate: when the channel already has a + /// pending splice that requires a higher feerate to be replaced, or when the counterparty + /// initiates a splice concurrently and our contribution is added to theirs. + /// /// # Experimental API /// /// This API is experimental. Currently, a splice-out will be marked as an inbound payment if @@ -1993,12 +2021,14 @@ impl Node { /// in the future. pub fn splice_out( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, address: &Address, - splice_amount_sats: u64, + splice_amount_sats: u64, min_fee_rate: Option, ) -> Result<(), Error> { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } + let min_fee_rate = maybe_map_fee_rate_opt(min_fee_rate); + let open_channels = self.channel_manager.list_channels_with_counterparty(&counterparty_node_id); if let Some(channel_details) = @@ -2012,8 +2042,9 @@ impl Node { self.wallet.parse_and_validate_address(address)?; - let min_feerate = - self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); + let min_feerate = min_fee_rate.unwrap_or_else(|| { + self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding) + }); let max_feerate = max_funding_feerate(min_feerate); let funding_template = self diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3f7e6c1517..127fac2439 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1236,7 +1236,7 @@ pub async fn open_channel_with_all( pub async fn splice_in_with_all( node_a: &TestNode, node_b: &TestNode, user_channel_id: &UserChannelId, electrsd: &ElectrsD, ) { - node_a.splice_in_with_all(user_channel_id, node_b.node_id()).unwrap(); + node_a.splice_in_with_all(user_channel_id, node_b.node_id(), None).unwrap(); let splice_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); wait_for_tx(&electrsd.client, splice_txo.txid).await; @@ -1681,7 +1681,7 @@ pub(crate) async fn do_channel_full_cycle( let available_splice_out_sat = node_b.list_channels()[0].outbound_capacity_msat / 1000; let splice_out_sat = available_splice_out_sat / 2; assert!(splice_out_sat > 500_000); - node_b.splice_out(&user_channel_id_b, node_a.node_id(), &addr_a, splice_out_sat).unwrap(); + node_b.splice_out(&user_channel_id_b, node_a.node_id(), &addr_a, splice_out_sat, None).unwrap(); let splice_out_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); wait_for_tx(electrsd, splice_out_txo.txid).await; @@ -1703,7 +1703,7 @@ pub(crate) async fn do_channel_full_cycle( println!("\nA splices in the splice-out payment from B"); let splice_in_sat = splice_out_sat; - node_a.splice_in(&user_channel_id_a, node_b.node_id(), splice_in_sat).unwrap(); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), splice_in_sat, None).unwrap(); let splice_in_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); wait_for_tx(electrsd, splice_in_txo.txid).await; diff --git a/tests/common/scenarios/mod.rs b/tests/common/scenarios/mod.rs index 3a1066ca0d..addc34681d 100644 --- a/tests/common/scenarios/mod.rs +++ b/tests/common/scenarios/mod.rs @@ -239,7 +239,7 @@ pub(crate) async fn splice_in_scenario( ) .await; let ext_node_id = peer.get_node_id().await.unwrap(); - node.splice_in(&user_ch, ext_node_id, 500_000).unwrap(); + node.splice_in(&user_ch, ext_node_id, 500_000, None).unwrap(); let splice_txo = expect_splice_negotiated_event!(node, ext_node_id); wait_for_tx(electrs, splice_txo.txid).await; generate_blocks_and_wait(bitcoind, electrs, 6).await; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 404b846415..fdb308ea15 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2851,7 +2851,8 @@ async fn splice_channel() { &user_channel_id_a, node_b.node_id(), &address, - excessive_splice_out_sats + excessive_splice_out_sats, + None ), Err(NodeError::ChannelSplicingFailed), ); @@ -2861,11 +2862,11 @@ async fn splice_channel() { let amount_msat = 400_000_000; assert_eq!( - node_b.splice_in(&user_channel_id_b, node_b.node_id(), 5_000_000), + node_b.splice_in(&user_channel_id_b, node_b.node_id(), 5_000_000, None), Err(NodeError::ChannelSplicingFailed), ); assert_eq!( - node_b.splice_out(&user_channel_id_b, node_b.node_id(), &address, amount_msat / 1000), + node_b.splice_out(&user_channel_id_b, node_b.node_id(), &address, amount_msat / 1000, None), Err(NodeError::ChannelSplicingFailed), ); assert_eq!( @@ -2874,7 +2875,7 @@ async fn splice_channel() { ); // Splice-in funds for Node B so that it has outbound liquidity to make a payment - node_b.splice_in(&user_channel_id_b, node_a.node_id(), 4_000_000).unwrap(); + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 4_000_000, None).unwrap(); let txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); @@ -2932,7 +2933,9 @@ async fn splice_channel() { // Splice-out funds for Node A from the payment sent by Node B let address = node_a.onchain_payment().new_address().unwrap(); - node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, amount_msat / 1000).unwrap(); + node_a + .splice_out(&user_channel_id_a, node_b.node_id(), &address, amount_msat / 1000, None) + .unwrap(); let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); @@ -3044,7 +3047,7 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { ); // 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(); + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000, None).unwrap(); let original_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); @@ -3075,7 +3078,7 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // Re-splicing the pending splice we already contributed to is rejected; the RBF guard points at // bump_channel_funding_fee instead. assert_eq!( - node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000), + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000, None), Err(NodeError::ChannelSplicingFailed), ); @@ -3284,7 +3287,7 @@ async fn splice_payment_reorged_to_unconfirmed() { let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); // node_b splices in, recording a funding payment it contributed to. - node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000, None).unwrap(); let splice_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); wait_for_tx(&electrsd.client, splice_txo.txid).await; // node_b recorded what the splice's transaction is when signing it, so the sync below files it @@ -3362,7 +3365,7 @@ async fn splice_in_rbf_joins_counterparty_splice() { // node_b (which didn't fund the channel open, so holds the on-chain balance) initiates a // splice-in; node_a does not contribute to this first candidate. - node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000, None).unwrap(); let counterparty_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); wait_for_tx(&electrsd.client, counterparty_txo.txid).await; node_a.sync_wallets().unwrap(); @@ -3371,7 +3374,7 @@ async fn splice_in_rbf_joins_counterparty_splice() { // node_a contributes to the pending splice via RBF. Before honoring the funding template's RBF // minimum feerate, this was rejected with FeeRateBelowRbfMinimum because node_a's funding // feerate estimate sat below the minimum required to replace the in-flight transaction. - node_a.splice_in(&user_channel_id_a, node_b.node_id(), 100_000).unwrap(); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 100_000, None).unwrap(); let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); assert_ne!(counterparty_txo, rbf_txo, "node_a's RBF should produce a different funding txo"); @@ -3444,7 +3447,7 @@ async fn open_and_splice_from_counterparty( 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()); - node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000, None).unwrap(); let counterparty_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); wait_for_tx(&electrsd.client, counterparty_txo.txid).await; node_a.sync_wallets().unwrap(); @@ -3626,7 +3629,7 @@ async fn open_and_join_counterparty_splice( 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()); - node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000, None).unwrap(); let first_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); let first_tx = wait_for_transaction(bitcoind, electrsd, first_txo.txid).await; wait_for_classified_funding_payment(node_b, first_txo.txid).await; @@ -3634,7 +3637,7 @@ async fn open_and_join_counterparty_splice( node_b.sync_wallets().unwrap(); deprioritise_transaction(bitcoind, first_txo.txid); - node_a.splice_in(&user_channel_id_a, node_b.node_id(), 100_000).unwrap(); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 100_000, None).unwrap(); let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); assert_ne!(first_txo, rbf_txo, "node A's round should replace node B's"); @@ -3774,7 +3777,7 @@ async fn signed_splice_round_the_monitor_watches_is_kept_at_close() { let broadcast_b = logs_b.count(BROADCAST_FUNDING); let resolved_a = logs_a.count(CLOSED_CHANNEL_ROUNDS_RESOLVED); - node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000).unwrap(); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000, None).unwrap(); // Recording the round writes what the transaction is before the round is signed, so node A does // not sign while those writes are held, and node B's `commitment_signed` is stashed until it // has. @@ -3890,7 +3893,7 @@ async fn broadcast_splice_round_lost_to_a_close_fails_its_payment() { .and_then(|channel| channel.funding_txo) .expect("the channel has a funding"); - node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap(); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000, None).unwrap(); let splice_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); wait_for_tx(&electrsd.client, splice_txo.txid).await; wait_for_classified_funding_payment(&node_a, splice_txo.txid).await; @@ -4055,7 +4058,7 @@ async fn signed_splice_round_the_monitor_does_not_watch_is_dropped_at_close() { let reclaimed_a = logs_a.count(RECLAIMED_ADDRESSES); let hold_b = Arc::clone(&store_b.serializer).write_owned().await; - node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000).unwrap(); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 200_000, None).unwrap(); assert!(logs_a.wait_for_count(SIGNED_FUNDING, signed_a + 1).await, "node A never signed"); let rbf_txid = only_signed_round_txid(&logs_a); assert_eq!(logs_b.count(SIGNED_FUNDING), signed_b, "node B signed with its writes held"); @@ -4131,7 +4134,7 @@ async fn superseded_zero_conf_splice_round_keeps_its_payment_at_close() { node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); - node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000).unwrap(); + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 1_000_000, None).unwrap(); let first = expect_splice_negotiated_event!(node_a, node_b.node_id()); wait_for_classified_funding_payment(&node_a, first.txid).await; // The zero-conf splice locks without confirmations, re-signaled as `ChannelReady`, and node A @@ -4141,7 +4144,7 @@ async fn superseded_zero_conf_splice_round_keeps_its_payment_at_close() { assert_eq!(logs_a.count(ROUND_LOCKED), 1, "the promotion of the first round was not recorded"); let address = node_a.onchain_payment().new_address().unwrap(); - node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 500_000).unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 500_000, None).unwrap(); let second = expect_splice_negotiated_event!(node_a, node_b.node_id()); wait_for_classified_funding_payment(&node_a, second.txid).await; expect_channel_ready_event!(node_a, node_b.node_id()); @@ -4224,7 +4227,7 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); // Node B contributes nothing to either round, so only node A hears of them. - node_a.splice_in_with_all(&user_channel_id_a, node_b.node_id()).unwrap(); + node_a.splice_in_with_all(&user_channel_id_a, node_b.node_id(), None).unwrap(); let first_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); let first_round = wait_for_transaction(&bitcoind, &electrsd, first_txo.txid).await; wait_for_classified_funding_payment(&node_a, first_txo.txid).await; @@ -5575,9 +5578,12 @@ async fn live_node_apis_return_not_running_after_stop() { node_a.open_channel(node_b_id, node_b_addr.clone(), 100_000, None, None, None), Err(NodeError::NotRunning) ); - assert_eq!(node_a.splice_in(&user_channel_id, node_b_id, 10_000), Err(NodeError::NotRunning)); assert_eq!( - node_a.splice_out(&user_channel_id, node_b_id, &btc_address, 10_000), + node_a.splice_in(&user_channel_id, node_b_id, 10_000, None), + Err(NodeError::NotRunning) + ); + assert_eq!( + node_a.splice_out(&user_channel_id, node_b_id, &btc_address, 10_000, None), Err(NodeError::NotRunning) ); assert_eq!( From b246b2a784fad1ad2d4d3a5316a969ef3302914c Mon Sep 17 00:00:00 2001 From: Shubham Kumar Date: Thu, 15 Oct 2026 09:06:49 +0530 Subject: [PATCH 2/3] Allow specifying a fee rate when fee-bumping a splice `Node::bump_channel_funding_fee` always started from our `ChannelFunding` feerate estimate, so the replacement couldn't be bumped to a chosen feerate, unlike the splice itself. Add the same optional `min_fee_rate`. If set, it takes the place of the estimate as the minimum feerate of the replacement: we still pay the pending splice's RBF minimum if that is higher, up to 1.5 times the minimum, and fail if replacing it requires more. If unset, behavior is unchanged. Developed with assistance from Claude Code. --- CHANGELOG.md | 2 ++ bindings/ldk_node.udl | 2 +- src/builder.rs | 2 +- src/lib.rs | 13 +++++++++++-- tests/integration_tests_rust.rs | 8 ++++---- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4a5ab0e5..4d7c631bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,8 @@ transaction instead of a feerate estimate retrieved from the configured chain source. For `splice_in_with_all`, the amount spliced in is determined at `min_fee_rate`, or at the estimate if unset. + `Node::bump_channel_funding_fee` takes the same optional `min_fee_rate` for the replacement + transaction. ## Bug Fixes and Improvements - Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 960aa8c211..326de194da 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -96,7 +96,7 @@ interface Node { [Throws=NodeError] void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, [ByRef]Address address, u64 splice_amount_sats, FeeRate? min_fee_rate); [Throws=NodeError] - void bump_channel_funding_fee([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); + void bump_channel_funding_fee([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, FeeRate? min_fee_rate); [Throws=NodeError] void close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); [Throws=NodeError] diff --git a/src/builder.rs b/src/builder.rs index 4ca80d4f6b..54e79f4b3a 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2821,7 +2821,7 @@ mod tests { ); assert_eq!(node.splice_in(&user_channel_id, node_id, 1_000, None), Err(Error::NotRunning)); assert_eq!( - node.bump_channel_funding_fee(&user_channel_id, node_id), + node.bump_channel_funding_fee(&user_channel_id, node_id, None), Err(Error::NotRunning) ); assert_eq!(node.bolt11_payment().fail_for_id(payment_id), Err(Error::NotRunning)); diff --git a/src/lib.rs b/src/lib.rs index 18f5da48b1..a5f2a72e18 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2108,20 +2108,29 @@ impl Node { /// Fee-bumps the pending splice on a channel by replacing its in-flight funding transaction /// (RBF). The splice's amount and destination are preserved; only the fee rate is raised. /// Errors if the channel has no pending splice to bump. + /// + /// If `min_fee_rate` is set, it will be used as the minimum feerate for the replacement instead + /// of a feerate estimate retrieved from the configured chain source. As with the estimate, we + /// pay the feerate required to replace the pending splice if it is higher, but no more than 1.5 + /// times this feerate: if replacing it requires more, the bump fails. pub fn bump_channel_funding_fee( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, + min_fee_rate: Option, ) -> Result<(), Error> { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } + let min_fee_rate = maybe_map_fee_rate_opt(min_fee_rate); + 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 min_feerate = min_fee_rate.unwrap_or_else(|| { + self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding) + }); let funding_template = self .channel_manager diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index fdb308ea15..c989f7907e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3042,7 +3042,7 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // bump_channel_funding_fee should fail when there's no pending splice assert_eq!( - node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id()), + node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id(), None), Err(NodeError::ChannelSplicingFailed), ); @@ -3083,7 +3083,7 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { ); // bump_channel_funding_fee should succeed when there's a pending splice - node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id()).unwrap(); + node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id(), None).unwrap(); let rbf_txo = expect_splice_negotiated_event!(node_b, node_a.node_id()); @@ -4249,7 +4249,7 @@ async fn splice_rounds_discarded_while_the_channel_is_listed_fail_at_close() { // The bump pays little more than the first round, which bitcoind may refuse to replace for it: // have it replaced, so the mempool serves the bump. deprioritise_transaction(&bitcoind, first_txo.txid); - node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap(); + node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id(), None).unwrap(); let bump_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); assert_ne!(first_txo, bump_txo, "the bump produced the same funding"); wait_for_classified_funding_payment(&node_a, bump_txo.txid).await; @@ -5587,7 +5587,7 @@ async fn live_node_apis_return_not_running_after_stop() { Err(NodeError::NotRunning) ); assert_eq!( - node_a.bump_channel_funding_fee(&user_channel_id, node_b_id), + node_a.bump_channel_funding_fee(&user_channel_id, node_b_id, None), Err(NodeError::NotRunning) ); assert_eq!(node_a.close_channel(&user_channel_id, node_b_id), Err(NodeError::NotRunning)); From 11cb5000ef8a3e3db173408dff3d783d6512872f Mon Sep 17 00:00:00 2001 From: Shubham Kumar Date: Mon, 12 Oct 2026 19:36:02 +0530 Subject: [PATCH 3/3] Test splicing and fee-bumping at a specified fee rate Splice out, splice in and fee-bump the pending splice-in, and then splice in all funds, at feerates well above the one we estimate on regtest, and check that each splice transaction pays the requested feerate. Replacing the pending splice requires more than we would pay at our estimate, so the bump only succeeds at the requested feerate. As the amount to splice in is determined at the requested feerate, the final splice-in would otherwise fail to fund its fee. Developed with assistance from Claude Code. --- tests/integration_tests_rust.rs | 132 +++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index c989f7907e..080fb8c359 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -19,7 +19,7 @@ use bitcoin::hashes::hex::FromHex; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; use bitcoin::hex::DisplayHex; -use bitcoin::{Address, Amount, ScriptBuf, Transaction, Txid}; +use bitcoin::{Address, Amount, FeeRate, ScriptBuf, Transaction, Txid}; use common::logging::{ init_log_logger, validate_log_entry, CollectingLogWriter, MultiNodeLogger, TestLogWriter, }; @@ -2979,6 +2979,136 @@ async fn splice_channel() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_channel_with_fee_rate() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(5_000_000), + ) + .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()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Ten times the feerate our chain source estimates on regtest, so a splice that ignored the + // requested feerate would be easy to tell apart. + let fee_rate = FeeRate::from_sat_per_kwu(2_500); + + let address = node_a.onchain_payment().new_address().unwrap(); + node_a + .splice_out(&user_channel_id_a, node_b.node_id(), &address, 100_000, Some(fee_rate.into())) + .unwrap(); + + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, txo.txid).await; + wait_for_tx(&electrsd.client, txo.txid).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + assert_splice_pays_fee_rate(&node_a, &electrsd.client, txo.txid, fee_rate); + + // A pending splice is fee-bumped to a requested feerate. Replacing it takes a feerate well above + // our estimate, so the bump only succeeds at the requested one. Staying within 1.5 times the + // pending splice's feerate lets the replacement reuse its inputs, paying the higher fee from + // their change. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 100_000, Some(fee_rate.into())).unwrap(); + + let original_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, original_txo.txid).await; + wait_for_tx(&electrsd.client, original_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let bump_fee_rate = FeeRate::from_sat_per_kwu(3_000); + node_a + .bump_channel_funding_fee(&user_channel_id_a, node_b.node_id(), Some(bump_fee_rate.into())) + .unwrap(); + + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + assert_ne!(original_txo, txo, "RBF should produce a different funding txo"); + wait_for_classified_funding_payment(&node_a, txo.txid).await; + wait_for_tx(&electrsd.client, txo.txid).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + assert_splice_pays_fee_rate(&node_a, &electrsd.client, txo.txid, bump_fee_rate); + + // The amount spliced in is determined at the requested feerate, so the splice would fail to + // fund its fee if it were determined at our estimate instead. + node_a.splice_in_with_all(&user_channel_id_a, node_b.node_id(), Some(fee_rate.into())).unwrap(); + + let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + wait_for_classified_funding_payment(&node_a, txo.txid).await; + wait_for_tx(&electrsd.client, txo.txid).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + assert_splice_pays_fee_rate(&node_a, &electrsd.client, txo.txid, fee_rate); +} + +/// Asserts that the splice transaction `txid` we funded pays `fee_rate`. Our fee is determined from +/// an estimate of the transaction's weight, which may differ slightly from its actual weight. +/// +/// The payment is looked up by transaction rather than by id, as a fee-bumped splice keeps the id +/// of its first candidate. +fn assert_splice_pays_fee_rate( + node: &Node, electrs: &E, txid: Txid, fee_rate: FeeRate, +) { + let payment = node + .list_all_payments() + .into_iter() + .find(|p| matches!(p.kind, PaymentKind::Onchain { txid: t, .. } if t == txid)) + .unwrap(); + let fee_paid_sat = payment.fee_paid_msat.unwrap() / 1_000; + let weight_wu = electrs.transaction_get(&txid).unwrap().weight().to_wu(); + + let paid_sat_per_kwu = fee_paid_sat * 1_000 / weight_wu; + let requested_sat_per_kwu = fee_rate.to_sat_per_kwu(); + assert!( + paid_sat_per_kwu.abs_diff(requested_sat_per_kwu) <= requested_sat_per_kwu / 100, + "paid {} sat/kwu, requested {} sat/kwu", + paid_sat_per_kwu, + requested_sat_per_kwu + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn rbf_splice_channel() { run_rbf_splice_channel_test(false).await;