Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@
current chain tip now aborts with a new `BuildError::ChainTipFetchFailed` variant instead of
silently pinning the wallet birthday to genesis, which would have forced a full-history rescan
once the chain source became reachable again. (#884)
- Opening a channel with an exact amount now accounts for the funding transaction's fee when
checking the anchor reserve, and fails with `NodeError::InsufficientFunds` if the amount exceeds
what the corresponding `_with_all` variant would fund at the same fee rate. Previously, a high
fee rate could spend part of the anchor reserve, or the funding transaction could fail to be
built after the channel had already been negotiated.
- When acting as an LSPS2 service, we likewise account for the funding transaction's fee when
checking that a JIT channel can be opened while retaining the anchor reserve.

# 0.7.0 - Dec. 3, 2025
This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend.
Expand Down
1 change: 1 addition & 0 deletions src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2349,6 +2349,7 @@ fn build_with_store_internal(
Arc::clone(&channel_manager),
Arc::clone(&keys_manager),
Arc::clone(&tx_broadcaster),
Arc::clone(&fee_estimator),
Arc::clone(&kv_store),
Arc::clone(&config),
Arc::clone(&logger),
Expand Down
57 changes: 40 additions & 17 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1390,34 +1390,51 @@ impl Node {
con_cm.connect_peer_if_necessary(con_node_id, con_addr).await
})?;

let cur_anchor_reserve_sats =
total_anchor_channels_reserve_sats(&self.channel_manager, &self.config);
let new_channel_reserve = self.new_channel_anchor_reserve_sats(&peer_info.node_id)?;
let total_anchor_reserve_sats = cur_anchor_reserve_sats.saturating_add(new_channel_reserve);

let funding_fee_rate = fee_rate.unwrap_or_else(|| {
self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding)
});

let channel_amount_sats = match channel_amount_sats {
FundingAmount::Exact { amount_sats } => {
// Check funds availability after connection (includes anchor reserve
// calculation).
self.check_sufficient_onchain_funds(amount_sats, &peer_info.node_id, true)?;

// The funding transaction's fee is paid from the same on-chain funds, so we can't
// fund more than we would when opening the channel with all of them.
let max_amount = self
.wallet
.get_max_funding_amount(total_anchor_reserve_sats, funding_fee_rate)?;
if amount_sats > max_amount {
log_error!(
self.logger,
"Unable to create channel due to insufficient funds. Requested: {}sats, Maximum: {}sats (fee rate: {} sat/kw, anchor reserve: {}sats)",
amount_sats,
max_amount,
funding_fee_rate.to_sat_per_kwu(),
total_anchor_reserve_sats,
);
return Err(Error::InsufficientFunds);
}

amount_sats
},
FundingAmount::Max => {
// Determine max funding amount from all available on-chain funds.
let cur_anchor_reserve_sats =
total_anchor_channels_reserve_sats(&self.channel_manager, &self.config);
let new_channel_reserve =
self.new_channel_anchor_reserve_sats(&peer_info.node_id)?;
let total_anchor_reserve_sats =
cur_anchor_reserve_sats.saturating_add(new_channel_reserve);

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)?;
let amount = self
.wallet
.get_max_funding_amount(total_anchor_reserve_sats, funding_fee_rate)?;

log_info!(
self.logger,
"Opening channel with all balance: {}sats (fee rate: {} sat/kw, anchor reserve: {}sats)",
amount,
fee_rate.to_sat_per_kwu(),
funding_fee_rate.to_sat_per_kwu(),
total_anchor_reserve_sats,
);

Expand Down Expand Up @@ -1560,7 +1577,9 @@ impl Node {
/// 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.
/// retrieve a reasonable estimate from the configured chain source. As the funding
/// transaction's fee is paid from the same on-chain funds, `channel_amount_sats` can be at most
/// what [`Node::open_channel_with_all`] would fund at that fee rate.
///
/// Returns a [`UserChannelId`] allowing to locally keep track of the channel.
///
Expand Down Expand Up @@ -1606,7 +1625,9 @@ impl Node {
/// 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.
/// retrieve a reasonable estimate from the configured chain source. As the funding
/// transaction's fee is paid from the same on-chain funds, `channel_amount_sats` can be at most
/// what [`Node::open_announced_channel_with_all`] would fund at that fee rate.
///
/// Returns a [`UserChannelId`] allowing to locally keep track of the channel.
///
Expand Down Expand Up @@ -1740,7 +1761,9 @@ impl Node {
/// 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.
/// retrieve a reasonable estimate from the configured chain source. As the funding
/// transaction's fee is paid from the same on-chain funds, `channel_amount_sats` can be at most
/// what [`Node::open_0reserve_channel_with_all`] would fund at that fee rate.
///
/// Returns a [`UserChannelId`] allowing to locally keep track of the channel.
///
Expand Down
7 changes: 6 additions & 1 deletion src/liquidity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ use tokio::sync::oneshot;

use crate::builder::BuildError;
use crate::connection::ConnectionManager;
use crate::fee_estimator::OnchainFeeEstimator;
use crate::liquidity::client::lsps1::LSPS1Client;
use crate::liquidity::client::lsps2::LSPS2Client;
use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource};
Expand Down Expand Up @@ -251,6 +252,7 @@ where
channel_manager: Arc<ChannelManager>,
keys_manager: Arc<KeysManager>,
tx_broadcaster: Arc<Broadcaster>,
fee_estimator: Arc<OnchainFeeEstimator>,
kv_store: Arc<DynStore>,
config: Arc<Config>,
logger: L,
Expand All @@ -262,7 +264,8 @@ where
{
pub(crate) fn new(
wallet: Arc<Wallet>, channel_manager: Arc<ChannelManager>, keys_manager: Arc<KeysManager>,
tx_broadcaster: Arc<Broadcaster>, kv_store: Arc<DynStore>, config: Arc<Config>, logger: L,
tx_broadcaster: Arc<Broadcaster>, fee_estimator: Arc<OnchainFeeEstimator>,
kv_store: Arc<DynStore>, config: Arc<Config>, logger: L,
) -> Self {
let lsp_nodes = Vec::new();
let lsps2_service = None;
Expand All @@ -273,6 +276,7 @@ where
channel_manager,
keys_manager,
tx_broadcaster,
fee_estimator,
kv_store,
config,
logger,
Expand Down Expand Up @@ -367,6 +371,7 @@ where
lsps2_service: Arc::new(LSPS2ServiceLiquiditySource {
lsps2_service: self.lsps2_service,
wallet: self.wallet,
fee_estimator: self.fee_estimator,
channel_manager: self.channel_manager,
peer_manager: RwLock::new(None),
keys_manager: self.keys_manager,
Expand Down
20 changes: 20 additions & 0 deletions src/liquidity/service/lsps2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams;
use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig;
use lightning_types::payment::PaymentHash;

use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator};
use crate::logger::{log_error, LdkLogger};
use crate::types::{ChannelManager, KeysManager, LiquidityManager, PeerManager, Wallet};
use crate::{total_anchor_channels_reserve_sats, Config};
Expand All @@ -40,6 +41,7 @@ where
{
pub(crate) lsps2_service: Option<LSPS2Service>,
pub(crate) wallet: Arc<Wallet>,
pub(crate) fee_estimator: Arc<OnchainFeeEstimator>,
pub(crate) channel_manager: Arc<ChannelManager>,
pub(crate) peer_manager: RwLock<Option<Weak<PeerManager>>>,
pub(crate) keys_manager: Arc<KeysManager>,
Expand Down Expand Up @@ -474,6 +476,24 @@ where
return;
}

// The funding transaction's fee is paid from the same on-chain funds, so we can't
// fund more than we would when opening a channel with all of them.
let total_anchor_reserve_sats =
cur_anchor_reserve_sats.saturating_add(additional_reserve_required);
let funding_fee_rate =
self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding);
let max_amount = self
.wallet
.get_max_funding_amount(total_anchor_reserve_sats, funding_fee_rate)
.unwrap_or(0);
if channel_amount_sats > max_amount {
log_error!(self.logger,
"Unable to create channel due to insufficient funds. Requested: {}sats, Maximum: {}sats (fee rate: {} sat/kw, anchor reserve: {}sats)",
channel_amount_sats, max_amount, funding_fee_rate.to_sat_per_kwu(), total_anchor_reserve_sats,
);
return;
}

let mut config = self.channel_manager.get_current_config().clone();

// If we act as an LSPS2 service, the HTLC-value-in-flight must be 100% of the
Expand Down
180 changes: 180 additions & 0 deletions tests/integration_tests_rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7473,3 +7473,183 @@ async fn open_channel_with_fee_rate_override() {
node_c.stop().unwrap();
node_d.stop().unwrap();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn open_channel_fee_does_not_spend_anchor_reserve() {
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);

// Node B needs funds of its own to retain an anchor reserve, otherwise it rejects the anchor
// channel and we fall back to a channel type without one.
let premine_amount_sat = 1_000_000;
premine_and_distribute_funds(
&bitcoind.client,
&electrsd.client,
vec![
node_a.onchain_payment().new_address().unwrap(),
node_b.onchain_payment().new_address().unwrap(),
],
Amount::from_sat(premine_amount_sat),
)
.await;
node_a.sync_wallets().unwrap();
node_b.sync_wallets().unwrap();

// The channel amount and the anchor reserve fit in our funds, but at 100 sat/vB the funding
// transaction's fee of about 15,000 sats would have to come out of the reserve.
let anchor_reserve_sat = 25_000;
let channel_amount_sat = premine_amount_sat - anchor_reserve_sat - 5_000;
let fee_rate = bitcoin::FeeRate::from_sat_per_vb(100).unwrap();
let address_b = node_b.listening_addresses().unwrap().first().unwrap().clone();
assert_eq!(
Err(NodeError::InsufficientFunds),
node_a.open_channel(
node_b.node_id(),
address_b.clone(),
channel_amount_sat,
None,
Some(fee_rate.into()),
None,
)
);
assert!(node_a.list_channels().is_empty());

// At the ~1 sat/vB the estimator yields on regtest, the fee fits next to the reserve.
node_a.open_channel(node_b.node_id(), address_b, channel_amount_sat, None, None, None).unwrap();
let funding_txo = 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.txid).await;

node_a.sync_wallets().unwrap();
let balances = node_a.list_balances();
assert!(
balances.total_onchain_balance_sats >= anchor_reserve_sat,
"the funding transaction left {}sats on-chain, less than the {}sats anchor reserve",
balances.total_onchain_balance_sats,
anchor_reserve_sat,
);
assert_eq!(balances.total_anchor_channels_reserve_sats, anchor_reserve_sat);

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

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn lsps2_service_funding_fee_does_not_spend_anchor_reserve() {
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap());

let mut sync_config = EsploraSyncConfig::default();
sync_config.background_sync_config = None;

let channel_opening_fee_ppm = 10_000;
let channel_over_provisioning_ppm = 100_000;
let lsps2_service_config = LSPS2ServiceConfig {
require_token: None,
advertise_service: false,
channel_opening_fee_ppm,
channel_over_provisioning_ppm,
max_payment_size_msat: 1_000_000_000,
min_payment_size_msat: 0,
min_channel_lifetime: 100,
min_channel_opening_fee_msat: 1_000,
max_client_to_self_delay: 1024,
client_trusts_lsp: false,
disable_client_reserve: false,
};

let service_config = random_config();
let service_logs = Arc::new(CollectingLogWriter::new());
setup_builder!(service_builder, service_config.node_config);
service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config));
service_builder.enable_liquidity_provider(lsps2_service_config);
service_builder.set_custom_logger(service_logs.clone());
let service_node = service_builder.build(service_config.node_entropy.into()).unwrap();
service_node.start().unwrap();
let service_node_id = service_node.node_id();
let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone();

let client_config = random_config();
setup_builder!(client_builder, client_config.node_config);
client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config));
client_builder.add_liquidity_source(service_node_id, service_addr, None, true);
let client_node = client_builder.build(client_config.node_entropy.into()).unwrap();
client_node.start().unwrap();

let payer_config = random_config();
setup_builder!(payer_builder, payer_config.node_config);
payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config));
let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap();
payer_node.start().unwrap();

// The service opens a channel for the payment minus the opening fee, plus over-provisioning.
let jit_amount_msat = 100_000_000;
let opening_fee_msat = jit_amount_msat * channel_opening_fee_ppm as u64 / 1_000_000;
let amt_to_forward_msat = jit_amount_msat - opening_fee_msat;
let over_provisioning_msat =
amt_to_forward_msat * channel_over_provisioning_ppm as u64 / 1_000_000;
let channel_amount_sat = (amt_to_forward_msat + over_provisioning_msat) / 1000;

// The service's funds cover the channel and the anchor reserves of both the payer's channel
// and the new one, but not the funding transaction's fee on top.
let anchor_reserve_sat = 25_000;
let service_funds_sat = channel_amount_sat + 2 * anchor_reserve_sat + 100;

premine_and_distribute_funds(
&bitcoind.client,
&electrsd.client,
vec![payer_node.onchain_payment().new_address().unwrap()],
Amount::from_sat(10_000_000),
)
.await;
distribute_funds_unconfirmed(
&bitcoind.client,
&electrsd.client,
vec![service_node.onchain_payment().new_address().unwrap()],
Amount::from_sat(service_funds_sat),
)
.await;
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await;
service_node.sync_wallets().unwrap();
payer_node.sync_wallets().unwrap();

open_channel(&payer_node, &service_node, 5_000_000, false, &electrsd).await;
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
service_node.sync_wallets().unwrap();
payer_node.sync_wallets().unwrap();
expect_channel_ready_event!(payer_node, service_node.node_id());
expect_channel_ready_event!(service_node, payer_node.node_id());

let invoice_description =
Bolt11InvoiceDescription::Direct(Description::new(String::from("reserve")).unwrap());
let jit_invoice = client_node
.bolt11_payment()
.receive_via_jit_channel(jit_amount_msat, &invoice_description.into(), 1024, None)
.unwrap();
payer_node.bolt11_payment().send(&jit_invoice, None).unwrap();

// The check for the channel amount plus the anchor reserves passes, but the one counting the
// funding fee doesn't, so the service doesn't open the channel.
assert!(
service_logs
.wait_for("Unable to create channel due to insufficient funds. Requested:")
.await,
"the service didn't refuse to open the channel"
);
assert!(
!service_logs.contains("Unable to create channel due to insufficient funds. Available:")
);
assert!(service_node
.list_channels()
.iter()
.all(|channel| channel.counterparty.node_id != client_node.node_id()));
assert_eq!(
service_node.list_balances().spendable_onchain_balance_sats,
service_funds_sat - anchor_reserve_sat
);

service_node.stop().unwrap();
client_node.stop().unwrap();
payer_node.stop().unwrap();
}
Loading