From f19fbc2800ccdd124fd1bb72c12bba1aa54d9054 Mon Sep 17 00:00:00 2001 From: Evan Kaloudis Date: Thu, 17 Sep 2026 13:21:50 -0400 Subject: [PATCH 1/6] Allow specifying a fee rate for channel-open funding transactions Add an optional fee-rate override to channel-open APIs and use it for funding transactions and maximum funding amounts. Clear pending overrides when channels close. Update Rust, Python, and Kotlin call sites for the new parameter. --- CHANGELOG.md | 4 + .../lightningdevkit/ldknode/LibraryTest.kt | 2 +- bindings/ldk_node.udl | 12 +-- bindings/python/src/ldk_node/test_ldk_node.py | 2 +- src/builder.rs | 1 + src/event.rs | 16 +++- src/lib.rs | 73 +++++++++++++++++-- src/wallet/mod.rs | 6 +- tests/common/mod.rs | 6 ++ tests/common/scenarios/channel.rs | 2 +- tests/integration_tests_rust.rs | 25 +++++-- tests/upgrade_downgrade_tests.rs | 1 + 12 files changed, 123 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f8f563c7c..9a2d8213cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ `Event::PaymentClaimable`. ## Feature and API updates +- All `Node::open_channel` variants now take a trailing optional `FeeRate` that, when set, is + used for the channel's funding transaction instead of the internal fee estimation. For the + `_with_all` variants it also determines the rate at which the maximum funding amount is + computed. When unset, behavior is unchanged. - Language-binding `Mnemonic` objects can be generated or constructed from entropy and expose their words, word indices, word count, entropy, checksum, and passphrase-derived seed. - `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/test/kotlin/org/lightningdevkit/ldknode/LibraryTest.kt b/bindings/kotlin/ldk-node-jvm/lib/src/test/kotlin/org/lightningdevkit/ldknode/LibraryTest.kt index 96cccfa96e..6c23e58850 100644 --- a/bindings/kotlin/ldk-node-jvm/lib/src/test/kotlin/org/lightningdevkit/ldknode/LibraryTest.kt +++ b/bindings/kotlin/ldk-node-jvm/lib/src/test/kotlin/org/lightningdevkit/ldknode/LibraryTest.kt @@ -253,7 +253,7 @@ class LibraryTest { assertEquals(100000uL, totalBalance1) assertEquals(100000uL, totalBalance2) - node1.openChannel(nodeId2, listenAddress2, 50000u, null, null) + node1.openChannel(nodeId2, listenAddress2, 50000u, null, null, null) val channelPendingEvent1 = node1.waitNextEvent() println("Got event: $channelPendingEvent1") diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index df6aa18f8d..fce2716de7 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -78,17 +78,17 @@ interface Node { [Throws=NodeError] void disconnect(PublicKey node_id); [Throws=NodeError] - UserChannelId open_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, ChannelConfig? channel_config); + UserChannelId open_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); [Throws=NodeError] - UserChannelId open_announced_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, ChannelConfig? channel_config); + UserChannelId open_announced_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); [Throws=NodeError] - UserChannelId open_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, ChannelConfig? channel_config); + UserChannelId open_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); [Throws=NodeError] - UserChannelId open_announced_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, ChannelConfig? channel_config); + UserChannelId open_announced_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); [Throws=NodeError] - UserChannelId open_0reserve_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, ChannelConfig? channel_config); + UserChannelId open_0reserve_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); [Throws=NodeError] - UserChannelId open_0reserve_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, ChannelConfig? channel_config); + UserChannelId open_0reserve_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); [Throws=NodeError] void splice_in([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, u64 splice_amount_sats); [Throws=NodeError] diff --git a/bindings/python/src/ldk_node/test_ldk_node.py b/bindings/python/src/ldk_node/test_ldk_node.py index 11699da0af..6f785fbaa0 100644 --- a/bindings/python/src/ldk_node/test_ldk_node.py +++ b/bindings/python/src/ldk_node/test_ldk_node.py @@ -159,7 +159,7 @@ def fund_nodes(node_1, node_2, esplora_endpoint, amount_sats=100000): node_2.sync_wallets() def open_channel_and_wait_ready(node_1, node_2, node_id_2, listening_address_2, esplora_endpoint, channel_amount_sats=50000): - node_1.open_channel(node_id_2, listening_address_2, channel_amount_sats, None, None) + node_1.open_channel(node_id_2, listening_address_2, channel_amount_sats, None, None, None) channel_pending_event_1 = expect_event(node_1, Event.CHANNEL_PENDING) expect_event(node_2, Event.CHANNEL_PENDING) diff --git a/src/builder.rs b/src/builder.rs index 1158044e47..0a2b5b2db9 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2556,6 +2556,7 @@ fn build_with_store_internal( node_metrics, om_mailbox, async_payments_role, + pending_funding_fee_rates: Arc::new(Mutex::new(std::collections::HashMap::new())), #[cfg(feature = "unified-payments")] hrn_resolver, prober, diff --git a/src/event.rs b/src/event.rs index 871e4656bc..47a86ba71a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -7,13 +7,13 @@ use core::future::Future; use core::task::{Poll, Waker}; -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::ops::Deref; use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint}; +use bitcoin::{Amount, FeeRate, OutPoint}; use lightning::blinded_path::message::NextMessageHop; use lightning::events::bump_transaction::BumpTransactionEvent; #[cfg(not(feature = "uniffi"))] @@ -574,6 +574,7 @@ where runtime: Arc, logger: L, config: Arc, + pending_funding_fee_rates: Arc>>, } impl EventHandler @@ -590,6 +591,7 @@ where keys_manager: Arc, static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, prober: Option>, runtime: Arc, logger: L, config: Arc, + pending_funding_fee_rates: Arc>>, ) -> Self { Self { event_queue, @@ -611,6 +613,7 @@ where runtime, logger, config, + pending_funding_fee_rates, } } @@ -750,6 +753,10 @@ where let cur_height = self.channel_manager.current_best_block().height; let locktime = LockTime::from_height(cur_height).unwrap_or(LockTime::ZERO); + // Look up and remove any pending fee-rate override for this channel. + let fee_rate_override = + self.pending_funding_fee_rates.lock().expect("lock").remove(&user_channel_id); + // Sign the final funding transaction and broadcast it. let channel_amount = Amount::from_sat(channel_value_satoshis); let funding_transaction = self @@ -759,6 +766,7 @@ where channel_amount, confirmation_target, locktime, + fee_rate_override, ) .await; match funding_transaction { @@ -1935,6 +1943,10 @@ where } => { log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason); + // Clean up any funding fee-rate override still pending if the channel went + // away before the funding transaction was generated. + self.pending_funding_fee_rates.lock().expect("lock").remove(&user_channel_id); + // `counterparty_node_id` has been set on every `ChannelClosed` since LDK 0.0.117. let counterparty_node_id = counterparty_node_id .expect("counterparty_node_id is always set since LDK 0.0.117"); diff --git a/src/lib.rs b/src/lib.rs index fbb65b0005..33033e21a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,7 +55,7 @@ //! //! let node_id = PublicKey::from_str("NODE_ID").unwrap(); //! let node_addr = SocketAddress::from_str("IP_ADDR:PORT").unwrap(); -//! node.open_channel(node_id, node_addr, 10000, None, None).unwrap(); +//! node.open_channel(node_id, node_addr, 10000, None, None, None).unwrap(); //! //! let event = node.wait_next_event(); //! println!("EVENT: {:?}", event); @@ -213,6 +213,24 @@ type NodeFeatures = Arc; #[cfg(feature = "uniffi")] uniffi::include_scaffolding!("ldk_node"); +#[cfg(not(feature = "uniffi"))] +type FfiFeeRate = bitcoin::FeeRate; +#[cfg(feature = "uniffi")] +type FfiFeeRate = Arc; + +macro_rules! maybe_map_fee_rate_opt { + ($fee_rate_opt:expr) => {{ + #[cfg(not(feature = "uniffi"))] + { + $fee_rate_opt + } + #[cfg(feature = "uniffi")] + { + $fee_rate_opt.map(|f| *f) + } + }}; +} + #[cfg(cycle_tests)] /// A list of [`Weak`]s which can be used to check that a [`Node`]'s inner fields are being /// properly released after the [`Node`] is dropped. @@ -280,6 +298,7 @@ pub struct Node { node_metrics: Arc, om_mailbox: Option>, async_payments_role: Option, + pending_funding_fee_rates: Arc>>, #[cfg(feature = "unified-payments")] hrn_resolver: HRNResolver, prober: Option>, @@ -700,6 +719,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), + Arc::clone(&self.pending_funding_fee_rates), )); if let Some(prober) = self.prober.clone() { @@ -1348,6 +1368,7 @@ impl Node { &self, node_id: PublicKey, address: SocketAddress, channel_amount_sats: FundingAmount, push_to_counterparty_msat: Option, channel_config: Option, announce_for_forwarding: bool, disable_counterparty_reserve: bool, + fee_rate: Option, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); @@ -1380,8 +1401,9 @@ impl Node { self.new_channel_anchor_reserve_sats(&peer_info.node_id)?; let total_anchor_reserve_sats = cur_anchor_reserve_sats + new_channel_reserve; - let fee_rate = - self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); + let fee_rate = fee_rate.unwrap_or_else(|| { + self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding) + }); let amount = self.wallet.get_max_funding_amount(total_anchor_reserve_sats, fee_rate)?; @@ -1450,6 +1472,12 @@ impl Node { zero_reserve_string, peer_info.node_id ); + if let Some(fee_rate) = fee_rate { + self.pending_funding_fee_rates + .lock() + .expect("lock") + .insert(user_channel_id, fee_rate); + } self.runtime.block_on(self.peer_store.add_peer(peer_info))?; Ok(UserChannelId(user_channel_id)) }, @@ -1525,13 +1553,18 @@ impl Node { /// [`AnchorChannelsConfig::per_channel_reserve_sats`] is available and will be retained before /// opening the channel. /// + /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll + /// retrieve a reasonable estimate from the configured chain source. + /// /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. /// /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_channel( &self, node_id: PublicKey, address: SocketAddress, channel_amount_sats: u64, push_to_counterparty_msat: Option, channel_config: Option, + fee_rate: Option, ) -> Result { + let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); self.open_channel_inner( node_id, address, @@ -1540,6 +1573,7 @@ impl Node { channel_config, false, false, + fee_rate_opt, ) } @@ -1561,18 +1595,23 @@ impl Node { /// [`AnchorChannelsConfig::per_channel_reserve_sats`] is available and will be retained before /// opening the channel. /// + /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll + /// retrieve a reasonable estimate from the configured chain source. + /// /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. /// /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_announced_channel( &self, node_id: PublicKey, address: SocketAddress, channel_amount_sats: u64, push_to_counterparty_msat: Option, channel_config: Option, + fee_rate: Option, ) -> Result { if let Err(err) = may_announce_channel(&self.config) { log_error!(self.logger, "Failed to open announced channel as the node hasn't been sufficiently configured to act as a forwarding node: {}", err); return Err(Error::ChannelCreationFailed); } + let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); self.open_channel_inner( node_id, address, @@ -1581,6 +1620,7 @@ impl Node { channel_config, true, false, + fee_rate_opt, ) } @@ -1595,13 +1635,17 @@ impl Node { /// channel counterparty on channel open. This can be useful to start out with the balance not /// entirely shifted to one side, therefore allowing to receive payments from the getgo. /// + /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll + /// retrieve a reasonable estimate from the configured chain source. + /// /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. /// /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_channel_with_all( &self, node_id: PublicKey, address: SocketAddress, push_to_counterparty_msat: Option, - channel_config: Option, + channel_config: Option, fee_rate: Option, ) -> Result { + let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); self.open_channel_inner( node_id, address, @@ -1610,6 +1654,7 @@ impl Node { channel_config, false, false, + fee_rate_opt, ) } @@ -1628,18 +1673,22 @@ impl Node { /// channel counterparty on channel open. This can be useful to start out with the balance not /// entirely shifted to one side, therefore allowing to receive payments from the getgo. /// + /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll + /// retrieve a reasonable estimate from the configured chain source. + /// /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. /// /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_announced_channel_with_all( &self, node_id: PublicKey, address: SocketAddress, push_to_counterparty_msat: Option, - channel_config: Option, + channel_config: Option, fee_rate: Option, ) -> Result { if let Err(err) = may_announce_channel(&self.config) { log_error!(self.logger, "Failed to open announced channel as the node hasn't been sufficiently configured to act as a forwarding node: {err}"); return Err(Error::ChannelCreationFailed); } + let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); self.open_channel_inner( node_id, address, @@ -1648,6 +1697,7 @@ impl Node { channel_config, true, false, + fee_rate_opt, ) } @@ -1667,13 +1717,18 @@ impl Node { /// [`AnchorChannelsConfig::per_channel_reserve_sats`] is available and will be retained before /// opening the channel. /// + /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll + /// retrieve a reasonable estimate from the configured chain source. + /// /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. /// /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_0reserve_channel( &self, node_id: PublicKey, address: SocketAddress, channel_amount_sats: u64, push_to_counterparty_msat: Option, channel_config: Option, + fee_rate: Option, ) -> Result { + let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); self.open_channel_inner( node_id, address, @@ -1682,6 +1737,7 @@ impl Node { channel_config, false, true, + fee_rate_opt, ) } @@ -1698,11 +1754,15 @@ impl Node { /// channel counterparty on channel open. This can be useful to start out with the balance not /// entirely shifted to one side, therefore allowing to receive payments from the getgo. /// + /// If `fee_rate` is set it will be used for the funding transaction. Otherwise we'll + /// retrieve a reasonable estimate from the configured chain source. + /// /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. pub fn open_0reserve_channel_with_all( &self, node_id: PublicKey, address: SocketAddress, push_to_counterparty_msat: Option, - channel_config: Option, + channel_config: Option, fee_rate: Option, ) -> Result { + let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); self.open_channel_inner( node_id, address, @@ -1711,6 +1771,7 @@ impl Node { channel_config, false, true, + fee_rate_opt, ) } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 13a8ef4e00..fd3b2bc78f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -608,9 +608,11 @@ impl Wallet { #[allow(deprecated)] pub(crate) async fn create_funding_transaction( &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, - locktime: LockTime, + locktime: LockTime, fee_rate: Option, ) -> Result { - let fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target); + // Use the set fee_rate or default to fee estimation. + let fee_rate = + fee_rate.unwrap_or_else(|| self.fee_estimator.estimate_fee_rate(confirmation_target)); let mut locked_persister = self.persister.lock().await; let (psbt, change_set) = { let mut locked_wallet = self.inner.lock().expect("lock"); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 91e23ca168..11ef3b071d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1134,6 +1134,7 @@ pub async fn open_channel_no_wait( funding_amount_sat, push_amount_msat, None, + None, ) .unwrap(); } else { @@ -1144,6 +1145,7 @@ pub async fn open_channel_no_wait( funding_amount_sat, push_amount_msat, None, + None, ) .unwrap(); } @@ -1187,6 +1189,7 @@ pub async fn open_channel_with_all( node_b.listening_addresses().unwrap().first().unwrap().clone(), None, None, + None, ) .unwrap(); } else { @@ -1196,6 +1199,7 @@ pub async fn open_channel_with_all( node_b.listening_addresses().unwrap().first().unwrap().clone(), None, None, + None, ) .unwrap(); } @@ -1284,6 +1288,7 @@ pub(crate) async fn do_channel_full_cycle( funding_amount_sat, Some(push_msat), None, + None, ) .unwrap(); } else { @@ -1294,6 +1299,7 @@ pub(crate) async fn do_channel_full_cycle( funding_amount_sat, Some(push_msat), None, + None, ) .unwrap(); } diff --git a/tests/common/scenarios/channel.rs b/tests/common/scenarios/channel.rs index 038a016ad8..10a8aaf0cc 100644 --- a/tests/common/scenarios/channel.rs +++ b/tests/common/scenarios/channel.rs @@ -25,7 +25,7 @@ pub(crate) async fn open_channel_to_external( let ext_node_id = peer.get_node_id().await.unwrap(); let ext_addr = peer.get_listening_address().await.unwrap(); - node.open_channel(ext_node_id, ext_addr, funding_amount_sat, push_msat, None).unwrap(); + node.open_channel(ext_node_id, ext_addr, funding_amount_sat, push_msat, None, None).unwrap(); let funding_txo = expect_channel_pending_event!(node, ext_node_id); super::super::wait_for_tx(electrs, funding_txo.txid).await; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 3da60800c6..eb8f817737 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -632,6 +632,7 @@ async fn channel_open_fails_when_funds_insufficient() { 120000, None, None, + None, ) ); } @@ -980,6 +981,7 @@ async fn split_underpaid_bolt11_payment() { channel_amount_sat, push_amount_msat, None, + None, ) .unwrap(); @@ -5003,23 +5005,30 @@ fn open_channel_variant( let address = node_b.listening_addresses().unwrap().first().unwrap().clone(); match variant { OpenChannelVariant::Standard => node_a - .open_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .open_channel(node_b.node_id(), address, channel_amount_sats, None, None, None) .map(|_| ()), OpenChannelVariant::Announced => node_a - .open_announced_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .open_announced_channel( + node_b.node_id(), + address, + channel_amount_sats, + None, + None, + None, + ) .map(|_| ()), OpenChannelVariant::ZeroReserve => node_a - .open_0reserve_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .open_0reserve_channel(node_b.node_id(), address, channel_amount_sats, None, None, None) .map(|_| ()), OpenChannelVariant::StandardWithAll => { - node_a.open_channel_with_all(node_b.node_id(), address, None, None).map(|_| ()) + node_a.open_channel_with_all(node_b.node_id(), address, None, None, None).map(|_| ()) }, OpenChannelVariant::AnnouncedWithAll => node_a - .open_announced_channel_with_all(node_b.node_id(), address, None, None) + .open_announced_channel_with_all(node_b.node_id(), address, None, None, None) + .map(|_| ()), + OpenChannelVariant::ZeroReserveWithAll => node_a + .open_0reserve_channel_with_all(node_b.node_id(), address, None, None, None) .map(|_| ()), - OpenChannelVariant::ZeroReserveWithAll => { - node_a.open_0reserve_channel_with_all(node_b.node_id(), address, None, None).map(|_| ()) - }, } } diff --git a/tests/upgrade_downgrade_tests.rs b/tests/upgrade_downgrade_tests.rs index 593dfbb069..fe31989c7a 100644 --- a/tests/upgrade_downgrade_tests.rs +++ b/tests/upgrade_downgrade_tests.rs @@ -83,6 +83,7 @@ async fn v0_7_for_hash_payments_can_be_manually_resolved_after_upgrade() { CHANNEL_AMOUNT_SAT, None, None, + None, ) .unwrap(); From 1669afc62b56ddbdc80c26819009fe602a4da59e Mon Sep 17 00:00:00 2001 From: Evan Kaloudis Date: Mon, 5 Oct 2026 17:13:17 -0400 Subject: [PATCH 2/6] Register channel-open fee-rate override before creating the channel Previously the override was inserted into `pending_funding_fee_rates` only after `create_channel` returned, so `FundingGenerationReady` could be handled first and fall back to the estimator's rate. Insert it before calling into LDK and remove it again if channel creation fails. --- src/lib.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 33033e21a8..c2f8e87315 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1442,6 +1442,12 @@ impl Node { .expect("a 16-byte slice should convert into a [u8; 16]"), ); + // Register the fee-rate override before creating the channel, as the + // `FundingGenerationReady` event may be handled before `create_channel` returns. + if let Some(fee_rate) = fee_rate { + self.pending_funding_fee_rates.lock().expect("lock").insert(user_channel_id, fee_rate); + } + let result = if disable_counterparty_reserve { self.channel_manager.create_channel_to_trusted_peer_0reserve( peer_info.node_id, @@ -1472,12 +1478,6 @@ impl Node { zero_reserve_string, peer_info.node_id ); - if let Some(fee_rate) = fee_rate { - self.pending_funding_fee_rates - .lock() - .expect("lock") - .insert(user_channel_id, fee_rate); - } self.runtime.block_on(self.peer_store.add_peer(peer_info))?; Ok(UserChannelId(user_channel_id)) }, @@ -1488,6 +1488,7 @@ impl Node { zero_reserve_string, e ); + self.pending_funding_fee_rates.lock().expect("lock").remove(&user_channel_id); Err(Error::ChannelCreationFailed) }, } From 39a957076b51e0391183f2165c66ef4f9206adb2 Mon Sep 17 00:00:00 2001 From: Evan Kaloudis Date: Mon, 5 Oct 2026 17:13:30 -0400 Subject: [PATCH 3/6] Move fee-rate FFI mapping into ffi module Replace the duplicated `maybe_map_fee_rate_opt!` macros in `lib.rs` and `payment/onchain.rs` with a `maybe_map_fee_rate_opt` function and a shared `FfiFeeRate` alias in `ffi/mod.rs`, next to the other `uniffi`/non-`uniffi` conversion helpers. --- src/ffi/mod.rs | 16 ++++++++++++++++ src/lib.rs | 32 +++++++------------------------- src/payment/onchain.rs | 31 +++++++------------------------ 3 files changed, 30 insertions(+), 49 deletions(-) diff --git a/src/ffi/mod.rs b/src/ffi/mod.rs index 32464d0445..0c25b8c506 100644 --- a/src/ffi/mod.rs +++ b/src/ffi/mod.rs @@ -31,6 +31,14 @@ pub fn maybe_wrap(ldk_type: impl Into) -> std::sync::Arc { std::sync::Arc::new(ldk_type.into()) } +#[cfg(feature = "uniffi")] +pub(crate) type FfiFeeRate = std::sync::Arc; + +#[cfg(feature = "uniffi")] +pub(crate) fn maybe_map_fee_rate_opt(fee_rate: Option) -> Option { + fee_rate.map(|f| *f) +} + #[cfg(not(feature = "uniffi"))] pub fn maybe_deref(value: &T) -> &T { value @@ -45,3 +53,11 @@ pub fn maybe_try_convert_enum(value: &T) -> Result<&T, crate::error::Error> { pub fn maybe_wrap(value: T) -> T { value } + +#[cfg(not(feature = "uniffi"))] +pub(crate) type FfiFeeRate = bitcoin::FeeRate; + +#[cfg(not(feature = "uniffi"))] +pub(crate) fn maybe_map_fee_rate_opt(fee_rate: Option) -> Option { + fee_rate +} diff --git a/src/lib.rs b/src/lib.rs index c2f8e87315..4c9cd167c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -200,7 +200,7 @@ pub use types::{ pub use vss_client; use crate::config::{LIQUIDITY_DISCOVERY_RETRY_INITIAL_DELAY, LIQUIDITY_DISCOVERY_RETRY_MAX_DELAY}; -use crate::ffi::{maybe_deref, maybe_wrap}; +use crate::ffi::{maybe_deref, maybe_map_fee_rate_opt, maybe_wrap, FfiFeeRate}; use crate::liquidity::Liquidity; use crate::scoring::setup_background_pathfinding_scores_sync; use crate::wallet::FundingAmount; @@ -213,24 +213,6 @@ type NodeFeatures = Arc; #[cfg(feature = "uniffi")] uniffi::include_scaffolding!("ldk_node"); -#[cfg(not(feature = "uniffi"))] -type FfiFeeRate = bitcoin::FeeRate; -#[cfg(feature = "uniffi")] -type FfiFeeRate = Arc; - -macro_rules! maybe_map_fee_rate_opt { - ($fee_rate_opt:expr) => {{ - #[cfg(not(feature = "uniffi"))] - { - $fee_rate_opt - } - #[cfg(feature = "uniffi")] - { - $fee_rate_opt.map(|f| *f) - } - }}; -} - #[cfg(cycle_tests)] /// A list of [`Weak`]s which can be used to check that a [`Node`]'s inner fields are being /// properly released after the [`Node`] is dropped. @@ -1565,7 +1547,7 @@ impl Node { push_to_counterparty_msat: Option, channel_config: Option, fee_rate: Option, ) -> Result { - let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); + let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.open_channel_inner( node_id, address, @@ -1612,7 +1594,7 @@ impl Node { return Err(Error::ChannelCreationFailed); } - let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); + let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.open_channel_inner( node_id, address, @@ -1646,7 +1628,7 @@ impl Node { &self, node_id: PublicKey, address: SocketAddress, push_to_counterparty_msat: Option, channel_config: Option, fee_rate: Option, ) -> Result { - let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); + let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.open_channel_inner( node_id, address, @@ -1689,7 +1671,7 @@ impl Node { return Err(Error::ChannelCreationFailed); } - let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); + let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.open_channel_inner( node_id, address, @@ -1729,7 +1711,7 @@ impl Node { push_to_counterparty_msat: Option, channel_config: Option, fee_rate: Option, ) -> Result { - let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); + let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.open_channel_inner( node_id, address, @@ -1763,7 +1745,7 @@ impl Node { &self, node_id: PublicKey, address: SocketAddress, push_to_counterparty_msat: Option, channel_config: Option, fee_rate: Option, ) -> Result { - let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); + let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.open_channel_inner( node_id, address, diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index ad0a2d46c7..141b2e3b23 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -14,29 +14,12 @@ use lightning::ln::channelmanager::PaymentId; use crate::config::Config; use crate::error::Error; +use crate::ffi::{maybe_map_fee_rate_opt, FfiFeeRate}; use crate::logger::{log_info, LdkLogger, Logger}; use crate::runtime::Runtime; use crate::types::{ChannelManager, Wallet}; use crate::wallet::OnchainSendAmount; -#[cfg(not(feature = "uniffi"))] -type FeeRate = bitcoin::FeeRate; -#[cfg(feature = "uniffi")] -type FeeRate = Arc; - -macro_rules! maybe_map_fee_rate_opt { - ($fee_rate_opt:expr) => {{ - #[cfg(not(feature = "uniffi"))] - { - $fee_rate_opt - } - #[cfg(feature = "uniffi")] - { - $fee_rate_opt.map(|f| *f) - } - }}; -} - /// A payment handler allowing to send and receive on-chain payments. /// /// Should be retrieved by calling [`Node::onchain_payment`]. @@ -94,9 +77,9 @@ impl OnchainPayment { /// /// [`BalanceDetails::total_anchor_channels_reserve_sats`]: crate::BalanceDetails::total_anchor_channels_reserve_sats pub fn send_to_address( - &self, address: &bitcoin::Address, amount_sats: u64, fee_rate: Option, + &self, address: &bitcoin::Address, amount_sats: u64, fee_rate: Option, ) -> Result { - let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); + let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.runtime.block_on(self.send_to_address_inner(address, amount_sats, fee_rate_opt)) } @@ -116,7 +99,7 @@ impl OnchainPayment { /// /// [`BalanceDetails::spendable_onchain_balance_sats`]: crate::balance::BalanceDetails::spendable_onchain_balance_sats pub fn send_all_to_address( - &self, address: &bitcoin::Address, retain_reserves: bool, fee_rate: Option, + &self, address: &bitcoin::Address, retain_reserves: bool, fee_rate: Option, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); @@ -130,7 +113,7 @@ impl OnchainPayment { OnchainSendAmount::AllDrainingReserve }; - let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); + let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.runtime.block_on(self.wallet.send_to_address(address, send_amount, fee_rate_opt)) } @@ -149,11 +132,11 @@ impl OnchainPayment { /// /// [`BalanceDetails::total_anchor_channels_reserve_sats`]: crate::BalanceDetails::total_anchor_channels_reserve_sats pub fn bump_fee_rbf( - &self, payment_id: PaymentId, fee_rate: Option, + &self, payment_id: PaymentId, fee_rate: Option, ) -> Result { let cur_anchor_reserve_sats = crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); - let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); + let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.runtime.block_on(self.wallet.bump_fee_rbf( payment_id, fee_rate_opt, From 8c674539502c0017445d8d617e563d5a99464c16 Mon Sep 17 00:00:00 2001 From: Evan Kaloudis Date: Mon, 5 Oct 2026 17:13:44 -0400 Subject: [PATCH 4/6] Take channel-open fee rate before channel config Move the `fee_rate` parameter of all `open_channel` variants ahead of `channel_config` so `channel_config` remains the last argument. --- CHANGELOG.md | 4 ++-- bindings/ldk_node.udl | 12 ++++++------ src/lib.rs | 18 +++++++++--------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a2d8213cb..cfdc5701a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,8 @@ `Event::PaymentClaimable`. ## Feature and API updates -- All `Node::open_channel` variants now take a trailing optional `FeeRate` that, when set, is - used for the channel's funding transaction instead of the internal fee estimation. For the +- All `Node::open_channel` variants now take an optional `FeeRate` (after + `push_to_counterparty_msat`) that, when set, is used for the channel's funding transaction instead of the internal fee estimation. For the `_with_all` variants it also determines the rate at which the maximum funding amount is computed. When unset, behavior is unchanged. - Language-binding `Mnemonic` objects can be generated or constructed from entropy and expose diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index fce2716de7..259ae41d55 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -78,17 +78,17 @@ interface Node { [Throws=NodeError] void disconnect(PublicKey node_id); [Throws=NodeError] - UserChannelId open_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); + UserChannelId open_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] - UserChannelId open_announced_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); + UserChannelId open_announced_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] - UserChannelId open_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); + UserChannelId open_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] - UserChannelId open_announced_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); + UserChannelId open_announced_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] - UserChannelId open_0reserve_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); + UserChannelId open_0reserve_channel(PublicKey node_id, SocketAddress address, u64 channel_amount_sats, u64? push_to_counterparty_msat, FeeRate? fee_rate, ChannelConfig? channel_config); [Throws=NodeError] - UserChannelId open_0reserve_channel_with_all(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, ChannelConfig? channel_config, FeeRate? fee_rate); + 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); [Throws=NodeError] diff --git a/src/lib.rs b/src/lib.rs index 4c9cd167c3..e2b721849d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1544,8 +1544,8 @@ impl Node { /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_channel( &self, node_id: PublicKey, address: SocketAddress, channel_amount_sats: u64, - push_to_counterparty_msat: Option, channel_config: Option, - fee_rate: Option, + push_to_counterparty_msat: Option, fee_rate: Option, + channel_config: Option, ) -> Result { let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.open_channel_inner( @@ -1586,8 +1586,8 @@ impl Node { /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_announced_channel( &self, node_id: PublicKey, address: SocketAddress, channel_amount_sats: u64, - push_to_counterparty_msat: Option, channel_config: Option, - fee_rate: Option, + push_to_counterparty_msat: Option, fee_rate: Option, + channel_config: Option, ) -> Result { if let Err(err) = may_announce_channel(&self.config) { log_error!(self.logger, "Failed to open announced channel as the node hasn't been sufficiently configured to act as a forwarding node: {}", err); @@ -1626,7 +1626,7 @@ impl Node { /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_channel_with_all( &self, node_id: PublicKey, address: SocketAddress, push_to_counterparty_msat: Option, - channel_config: Option, fee_rate: Option, + fee_rate: Option, channel_config: Option, ) -> Result { let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.open_channel_inner( @@ -1664,7 +1664,7 @@ impl Node { /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_announced_channel_with_all( &self, node_id: PublicKey, address: SocketAddress, push_to_counterparty_msat: Option, - channel_config: Option, fee_rate: Option, + fee_rate: Option, channel_config: Option, ) -> Result { if let Err(err) = may_announce_channel(&self.config) { log_error!(self.logger, "Failed to open announced channel as the node hasn't been sufficiently configured to act as a forwarding node: {err}"); @@ -1708,8 +1708,8 @@ impl Node { /// [`AnchorChannelsConfig::per_channel_reserve_sats`]: crate::config::AnchorChannelsConfig::per_channel_reserve_sats pub fn open_0reserve_channel( &self, node_id: PublicKey, address: SocketAddress, channel_amount_sats: u64, - push_to_counterparty_msat: Option, channel_config: Option, - fee_rate: Option, + push_to_counterparty_msat: Option, fee_rate: Option, + channel_config: Option, ) -> Result { let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.open_channel_inner( @@ -1743,7 +1743,7 @@ impl Node { /// Returns a [`UserChannelId`] allowing to locally keep track of the channel. pub fn open_0reserve_channel_with_all( &self, node_id: PublicKey, address: SocketAddress, push_to_counterparty_msat: Option, - channel_config: Option, fee_rate: Option, + fee_rate: Option, channel_config: Option, ) -> Result { let fee_rate_opt = maybe_map_fee_rate_opt(fee_rate); self.open_channel_inner( From 090dc4f44b6d8e95a49675bf41455ee3c636d44f Mon Sep 17 00:00:00 2001 From: Evan Kaloudis Date: Mon, 5 Oct 2026 17:13:44 -0400 Subject: [PATCH 5/6] Update README open_channel example for fee rate parameter --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b9d32c0a0c..b1d663ef67 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ fn main() { let node_id = PublicKey::from_str("NODE_ID").unwrap(); let node_addr = SocketAddress::from_str("IP_ADDR:PORT").unwrap(); - node.open_channel(node_id, node_addr, 10000, None, None).unwrap(); + node.open_channel(node_id, node_addr, 10000, None, None, None).unwrap(); let event = node.wait_next_event(); println!("EVENT: {:?}", event); From e1b7be0c60b5ec933061d9bd0908c423dd1080fa Mon Sep 17 00:00:00 2001 From: Evan Kaloudis Date: Mon, 5 Oct 2026 17:13:47 -0400 Subject: [PATCH 6/6] Test that channel-open fee-rate override is applied Open one channel with an exact amount and one via `open_channel_with_all` at 25 sat/vB and check the funding transaction pays that rate. The `_with_all` case also covers the maximum amount being computed at the overridden rate. --- tests/integration_tests_rust.rs | 85 +++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index eb8f817737..5384d078cf 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -34,6 +34,7 @@ use common::{ TestNode, TestStoreType, TestSyncStore, }; use electrsd::corepc_node::{self, Node as BitcoinD}; +use electrsd::electrum_client::ElectrumApi; use electrsd::ElectrsD; use ldk_node::config::{ AsyncPaymentsRole, EsploraSyncConfig, ForwardedPaymentTrackingMode, ADDRESS_POOL_SIZE, @@ -5310,3 +5311,87 @@ async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { cheap.stop().unwrap(); expensive.stop().unwrap(); } + +fn tx_fee_rate_sat_per_vb(electrs: &E, txid: Txid) -> f64 { + let tx = electrs.transaction_get(&txid).unwrap(); + let input_sats: u64 = tx + .input + .iter() + .map(|txin| { + let prev_tx = electrs.transaction_get(&txin.previous_output.txid).unwrap(); + prev_tx.output[txin.previous_output.vout as usize].value.to_sat() + }) + .sum(); + let output_sats: u64 = tx.output.iter().map(|txout| txout.value.to_sat()).sum(); + (input_sats - output_sats) as f64 / (tx.weight().to_wu() as f64 / 4.0) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn open_channel_with_fee_rate_override() { + 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 (node_c, node_d) = setup_two_nodes(&chain_source, false, false); + + let premine_amount_sat = 1_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![ + node_a.onchain_payment().new_address().unwrap(), + node_c.onchain_payment().new_address().unwrap(), + ], + Amount::from_sat(premine_amount_sat), + ) + .await; + node_a.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + + // Well above the ~1 sat/vB the estimator yields on regtest. + let fee_rate_sat_per_vb = 25; + let fee_rate = bitcoin::FeeRate::from_sat_per_vb(fee_rate_sat_per_vb).unwrap(); + + // Exact amount. + node_a + .open_channel( + node_b.node_id(), + node_b.listening_addresses().unwrap().first().unwrap().clone(), + 500_000, + None, + Some(fee_rate.into()), + None, + ) + .unwrap(); + let funding_txo_a = expect_channel_pending_event!(node_a, node_b.node_id()); + expect_channel_pending_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, funding_txo_a.txid).await; + let actual = tx_fee_rate_sat_per_vb(&electrsd.client, funding_txo_a.txid); + assert!( + (actual - fee_rate_sat_per_vb as f64).abs() < 0.5, + "funding tx paid {actual} sat/vB, expected {fee_rate_sat_per_vb} sat/vB" + ); + + // Max amount: the amount must be computed at the overridden rate, otherwise funding fails. + node_c + .open_channel_with_all( + node_d.node_id(), + node_d.listening_addresses().unwrap().first().unwrap().clone(), + None, + Some(fee_rate.into()), + None, + ) + .unwrap(); + let funding_txo_c = expect_channel_pending_event!(node_c, node_d.node_id()); + expect_channel_pending_event!(node_d, node_c.node_id()); + wait_for_tx(&electrsd.client, funding_txo_c.txid).await; + let actual = tx_fee_rate_sat_per_vb(&electrsd.client, funding_txo_c.txid); + assert!( + (actual - fee_rate_sat_per_vb as f64).abs() < 0.5, + "funding tx paid {actual} sat/vB, expected {fee_rate_sat_per_vb} sat/vB" + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); + node_d.stop().unwrap(); +}