From b9a0364cdf64676838aca845dde4959a258b6365 Mon Sep 17 00:00:00 2001 From: fmar Date: Thu, 29 Oct 2026 17:35:13 +0100 Subject: [PATCH] Expose anchor per-channel reserve in node config ldk-node defaults AnchorChannelsConfig::per_channel_reserve_sats to 25,000 sats, but ldk-server did not expose the setting. A node with no spendable on-chain balance therefore rejected every inbound anchor channel, including LSP-funded LSPS1 and LSPS2 channels, which blocks wallets designed to receive before depositing on-chain bitcoin. Add node.anchor_per_channel_reserve_sats, wired into ldk_node_config.anchor_channels_config.per_channel_reserve_sats, plus the matching --node-anchor-per-channel-reserve-sats CLI flag and LDK_SERVER_NODE_ANCHOR_PER_CHANNEL_RESERVE_SATS environment variable. Unset keeps the 25,000 sat default (AnchorChannelsConfig::default() per_channel_reserve_sats). Setting 0 accepts inbound anchor channels with no on-chain balance while leaving LDK's fee-bump handling enabled if funds arrive later. Document the security tradeoff: a zero or very low reserve can leave the node unable to fee-bump an urgent channel close until the on-chain wallet is funded. This is distinct from trusted_peers_no_reserve, which also makes LDK rely on the peer to confirm closing transactions. Note that trusted_peers_no_reserve is not currently configurable in LDK Server. The anchor reserve default test builds its TOML with lsps2_service_config_for_feature() so the feature-independent default is verified with and without experimental-lsps2-support. AI tools were used to help write this change and its tests. --- contrib/ldk-server-config.toml | 7 +++ docs/configuration.md | 27 ++++++++++- ldk-server/src/main.rs | 2 + ldk-server/src/util/config.rs | 86 +++++++++++++++++++++++++++++++++- 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/contrib/ldk-server-config.toml b/contrib/ldk-server-config.toml index efd3ade9..b68995ec 100644 --- a/contrib/ldk-server-config.toml +++ b/contrib/ldk-server-config.toml @@ -9,6 +9,13 @@ alias = "ldk_server" # Lightning node alias #rgs_server_url = "https://rapidsync.lightningdevkit.org/snapshot/v2/" # Optional: RGS URL for rapid gossip sync #async_payments_role = "client" # Optional async payments role: "client" or "server" #enable_zero_fee_commitments = false # Enable zero-fee commitment channels (default: false) +#anchor_per_channel_reserve_sats = 25000 # On-chain sats reserved per anchor channel for fee-bumping (default: 25000) +# WARNING: setting `anchor_per_channel_reserve_sats = 0` allows inbound anchor channels +# (e.g. LSP-funded LSPS1/LSPS2 channels) with no on-chain balance. Anchor-channel HTLC +# claim transactions are signed with zero fee and need wallet inputs to confirm. With +# no on-chain funds, in-flight HTLC value can be lost if the counterparty claims via +# the other path first. If enable_zero_fee_commitments uses a zero-fee commitment, +# a zero reserve on an empty wallet means a force-close cannot confirm until funds arrive. # Forwarding history: "detailed" retains individual records for the current and previous hour, # then aggregates them into hourly channel-pair statistics and removes the individual records. diff --git a/docs/configuration.md b/docs/configuration.md index 898c9339..9f167b92 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,7 +52,8 @@ on macOS). Core node settings: which Bitcoin network to use, Lightning peer listening and announcement addresses, the gRPC bind address, node alias, optional Rapid Gossip Sync / pathfinding -scores URLs, the async payments role, and zero-fee commitment channels. +scores URLs, the async payments role, zero-fee commitment channels, and the per-channel +anchor reserve. Set `async_payments_role = "client"` to ask peers to hold HTLCs where possible, allowing this node to go offline. Set `async_payments_role = "server"` to hold async payment HTLCs @@ -64,6 +65,30 @@ falling back to other channel types supported by the peer. Enabling this setting source that supports Bitcoin Core's `submitpackage` RPC, and relays TRUC, P2A, and ephemeral dust. The default is `false`. +Set `anchor_per_channel_reserve_sats` to the amount of on-chain funds, in satoshis, that LDK +keeps available per anchor channel to fee-bump the commitment and closing transactions. The +default is `25000`, matching LDK Node. The reserve scales with the number of anchor channels, +reduces the spendable on-chain balance, is kept back from send-all transactions, and also applies +to outbound channel opens. Lowering it on a node with existing anchor channels makes some of +their previously reserved funds withdrawable. + +Inbound anchor channels are rejected while the node's spendable on-chain balance is below the +required reserve, which blocks LSP-funded channels (such as LSPS1 purchases or LSPS2 JIT +channels) on a wallet that has not been funded yet. Set the value to `0` to accept inbound +anchor channels with zero on-chain balance while keeping LDK's fee-bump handling enabled for +funds that arrive later. This differs from LDK Node's +`trusted_peers_no_reserve` (not currently configurable in LDK Server), which additionally +makes LDK rely on the peer to confirm channel closing transactions. + +> **Warning:** Anchor-channel HTLC claim transactions are signed with zero fee and need wallet +> inputs to confirm. With no on-chain funds, in-flight HTLC value can be lost if the counterparty +> claims via the other path first. A zero (or very low) reserve can also delay an urgent channel +> close. If `enable_zero_fee_commitments = true` results in a zero-fee commitment, a zero reserve +> on an empty wallet means a force-close cannot confirm at all until funds arrive. + +The same setting is available as `--node-anchor-per-channel-reserve-sats` or +`LDK_SERVER_NODE_ANCHOR_PER_CHANNEL_RESERVE_SATS`. + Set `forwarded_payment_tracking_mode = "detailed"` to store individual forwards. LDK Node retains these records for the current and previous one-hour buckets, then aggregates them into hourly channel-pair statistics and removes the individual records. diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index a8b16a65..27450b54 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -159,6 +159,8 @@ fn main() { ldk_node_config.hrn_config = config_file.hrn_config; ldk_node_config.anchor_channels_config.enable_zero_fee_commitments = config_file.enable_zero_fee_commitments; + ldk_node_config.anchor_channels_config.per_channel_reserve_sats = + config_file.anchor_per_channel_reserve_sats; // The server exposes receive-for-hash APIs, so unknown inbound BOLT11 HTLCs // must emit PaymentClaimable instead of being failed back. ldk_node_config.manually_handle_unknown_bolt11_payments = true; diff --git a/ldk-server/src/util/config.rs b/ldk-server/src/util/config.rs index d09bfdf3..2a54521d 100644 --- a/ldk-server/src/util/config.rs +++ b/ldk-server/src/util/config.rs @@ -17,7 +17,8 @@ use clap::Parser; use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::bitcoin::Network; use ldk_node::config::{ - AsyncPaymentsRole, ForwardedPaymentTrackingMode, HRNResolverConfig, HumanReadableNamesConfig, + AnchorChannelsConfig, AsyncPaymentsRole, ForwardedPaymentTrackingMode, HRNResolverConfig, + HumanReadableNamesConfig, }; use ldk_node::lightning::ln::msgs::SocketAddress; use ldk_node::lightning::routing::gossip::NodeAlias; @@ -78,6 +79,7 @@ pub struct Config { pub probing_config: Option, pub async_payments_role: Option, pub enable_zero_fee_commitments: bool, + pub anchor_per_channel_reserve_sats: u64, pub forwarded_payment_tracking_mode: ForwardedPaymentTrackingMode, pub metrics_enabled: bool, pub poll_metrics_interval: Option, @@ -173,6 +175,7 @@ struct ConfigBuilder { probing: Option, async_payments_role: Option, enable_zero_fee_commitments: Option, + anchor_per_channel_reserve_sats: Option, forwarded_payment_tracking_mode: Option, metrics_enabled: Option, poll_metrics_interval: Option, @@ -199,6 +202,8 @@ impl ConfigBuilder { node.async_payments_role.or(self.async_payments_role.clone()); self.enable_zero_fee_commitments = node.enable_zero_fee_commitments.or(self.enable_zero_fee_commitments); + self.anchor_per_channel_reserve_sats = + node.anchor_per_channel_reserve_sats.or(self.anchor_per_channel_reserve_sats); self.forwarded_payment_tracking_mode = node .forwarded_payment_tracking_mode .or(self.forwarded_payment_tracking_mode.clone()); @@ -355,6 +360,10 @@ impl ConfigBuilder { self.enable_zero_fee_commitments = Some(enable_zero_fee_commitments); } + if let Some(anchor_per_channel_reserve_sats) = args.node_anchor_per_channel_reserve_sats { + self.anchor_per_channel_reserve_sats = Some(anchor_per_channel_reserve_sats); + } + if let Some(mode) = &args.node_forwarded_payment_tracking_mode { self.forwarded_payment_tracking_mode = Some(mode.clone()); } @@ -738,6 +747,9 @@ impl ConfigBuilder { probing_config, async_payments_role, enable_zero_fee_commitments: self.enable_zero_fee_commitments.unwrap_or(false), + anchor_per_channel_reserve_sats: self + .anchor_per_channel_reserve_sats + .unwrap_or(AnchorChannelsConfig::default().per_channel_reserve_sats), forwarded_payment_tracking_mode, metrics_enabled, poll_metrics_interval, @@ -778,6 +790,7 @@ struct NodeConfig { pathfinding_scores_source_url: Option, async_payments_role: Option, enable_zero_fee_commitments: Option, + anchor_per_channel_reserve_sats: Option, forwarded_payment_tracking_mode: Option, rgs_server_url: Option, } @@ -1318,6 +1331,13 @@ pub struct ArgsConfig { )] node_enable_zero_fee_commitments: Option, + #[arg( + long, + env = "LDK_SERVER_NODE_ANCHOR_PER_CHANNEL_RESERVE_SATS", + help = "On-chain reserve in satoshis that LDK keeps available to fee-bump anchor channels. Defaults to 25000. Set to 0 to accept inbound anchor channels without any on-chain balance, at the risk of being unable to urgently close a channel while the wallet is unfunded." + )] + node_anchor_per_channel_reserve_sats: Option, + #[arg( long, env = "LDK_SERVER_NODE_FORWARDED_PAYMENT_TRACKING_MODE", @@ -1508,6 +1528,7 @@ mod tests { rgs_server_url = "https://rapidsync.lightningdevkit.org/snapshot/v2/" async_payments_role = "client" enable_zero_fee_commitments = true + anchor_per_channel_reserve_sats = 50000 [tls] cert_path = "/path/to/tls.crt" @@ -1574,6 +1595,7 @@ mod tests { pathfinding_scores_source_url: Some(String::from("https://example.com/")), node_async_payments_role: Some(String::from("server")), node_enable_zero_fee_commitments: Some(false), + node_anchor_per_channel_reserve_sats: Some(10000), node_forwarded_payment_tracking_mode: None, probing_strategy: None, probing_top_node_count: None, @@ -1616,6 +1638,7 @@ mod tests { pathfinding_scores_source_url: None, node_async_payments_role: None, node_enable_zero_fee_commitments: None, + node_anchor_per_channel_reserve_sats: None, node_forwarded_payment_tracking_mode: None, probing_strategy: None, probing_top_node_count: None, @@ -1736,6 +1759,7 @@ mod tests { probing_config: None, async_payments_role: Some(AsyncPaymentsRole::Client), enable_zero_fee_commitments: true, + anchor_per_channel_reserve_sats: 50000, forwarded_payment_tracking_mode: ForwardedPaymentTrackingMode::default(), metrics_enabled: false, poll_metrics_interval: None, @@ -1764,6 +1788,10 @@ mod tests { assert_eq!(config.pathfinding_scores_source_url, expected.pathfinding_scores_source_url); assert!(matches!(config.async_payments_role, Some(AsyncPaymentsRole::Client))); assert_eq!(config.enable_zero_fee_commitments, expected.enable_zero_fee_commitments); + assert_eq!( + config.anchor_per_channel_reserve_sats, + expected.anchor_per_channel_reserve_sats + ); assert_eq!( config.forwarded_payment_tracking_mode, expected.forwarded_payment_tracking_mode @@ -2713,6 +2741,7 @@ mod tests { probing_config: None, async_payments_role: Some(AsyncPaymentsRole::Server), enable_zero_fee_commitments: false, + anchor_per_channel_reserve_sats: 10000, forwarded_payment_tracking_mode: ForwardedPaymentTrackingMode::default(), metrics_enabled: false, poll_metrics_interval: None, @@ -2738,6 +2767,10 @@ mod tests { assert_eq!(config.pathfinding_scores_source_url, expected.pathfinding_scores_source_url); assert!(matches!(config.async_payments_role, Some(AsyncPaymentsRole::Server))); assert_eq!(config.enable_zero_fee_commitments, expected.enable_zero_fee_commitments); + assert_eq!( + config.anchor_per_channel_reserve_sats, + expected.anchor_per_channel_reserve_sats + ); assert_eq!( config.forwarded_payment_tracking_mode, expected.forwarded_payment_tracking_mode @@ -2845,6 +2878,7 @@ mod tests { probing_config: None, async_payments_role: Some(AsyncPaymentsRole::Server), enable_zero_fee_commitments: false, + anchor_per_channel_reserve_sats: 10000, forwarded_payment_tracking_mode: ForwardedPaymentTrackingMode::default(), metrics_enabled: false, poll_metrics_interval: None, @@ -2874,6 +2908,10 @@ mod tests { assert_eq!(config.pathfinding_scores_source_url, expected.pathfinding_scores_source_url); assert!(matches!(config.async_payments_role, Some(AsyncPaymentsRole::Server))); assert_eq!(config.enable_zero_fee_commitments, expected.enable_zero_fee_commitments); + assert_eq!( + config.anchor_per_channel_reserve_sats, + expected.anchor_per_channel_reserve_sats + ); assert_eq!( config.forwarded_payment_tracking_mode, expected.forwarded_payment_tracking_mode @@ -3367,6 +3405,52 @@ mod tests { } } + #[test] + fn test_accepts_anchor_per_channel_reserve_arg() { + for value in ["0", "25000"] { + let args_config = ArgsConfig::try_parse_from([ + "ldk-server", + "--node-anchor-per-channel-reserve-sats", + value, + ]) + .unwrap(); + + assert_eq!( + args_config.node_anchor_per_channel_reserve_sats, + Some(value.parse::().unwrap()) + ); + } + } + + #[test] + fn test_anchor_per_channel_reserve_defaults_to_25k_sats() { + let storage_path = std::env::temp_dir(); + let config_file_name = "test_anchor_per_channel_reserve_defaults_to_25k_sats.toml"; + + let toml_config = format!( + r#" + [node] + network = "regtest" + + [bitcoind] + rpc_address = "127.0.0.1:8332" + rpc_user = "bitcoind-testuser" + rpc_password = "bitcoind-testpassword" + {} + "#, + lsps2_service_config_for_feature() + ); + + fs::write(storage_path.join(config_file_name), toml_config).unwrap(); + + let mut args_config = empty_args_config(); + args_config.config_file = + Some(storage_path.join(config_file_name).to_string_lossy().to_string()); + + let config = load_config(&args_config).unwrap(); + assert_eq!(config.anchor_per_channel_reserve_sats, 25_000); + } + #[test] fn test_probing_args_override_strategy_specific_file_options() { let mut builder = ConfigBuilder {