From 30040c4ac4b4232651239e919addfb3571ddcee3 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sat, 11 Apr 2026 01:37:07 +0000 Subject: [PATCH 1/3] Add tests for zero reserve channels --- tests/common/mod.rs | 81 +++++++++++++++++++++---- tests/integration_tests_rust.rs | 101 ++++++++++++++++++++++++++++---- tests/integration_tests_vss.rs | 1 + 3 files changed, 163 insertions(+), 20 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index a44aee174..3f0c76f98 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -790,7 +790,7 @@ pub async fn splice_in_with_all( pub(crate) async fn do_channel_full_cycle( node_a: TestNode, node_b: TestNode, bitcoind: &BitcoindClient, electrsd: &E, allow_0conf: bool, - expect_anchor_channel: bool, force_close: bool, + allow_0reserve: bool, expect_anchor_channel: bool, force_close: bool, ) { let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -846,15 +846,27 @@ pub(crate) async fn do_channel_full_cycle( println!("\nA -- open_channel -> B"); let funding_amount_sat = 2_080_000; let push_msat = (funding_amount_sat / 2) * 1000; // balance the channel - node_a - .open_announced_channel( - node_b.node_id(), - node_b.listening_addresses().unwrap().first().unwrap().clone(), - funding_amount_sat, - Some(push_msat), - None, - ) - .unwrap(); + if allow_0reserve { + node_a + .open_0reserve_channel( + node_b.node_id(), + node_b.listening_addresses().unwrap().first().unwrap().clone(), + funding_amount_sat, + Some(push_msat), + None, + ) + .unwrap(); + } else { + node_a + .open_announced_channel( + node_b.node_id(), + node_b.listening_addresses().unwrap().first().unwrap().clone(), + funding_amount_sat, + Some(push_msat), + None, + ) + .unwrap(); + } assert_eq!(node_a.list_peers().first().unwrap().node_id, node_b.node_id()); assert!(node_a.list_peers().first().unwrap().is_persisted); @@ -913,6 +925,22 @@ pub(crate) async fn do_channel_full_cycle( node_b_anchor_reserve_sat ); + // Note that only node B has 0-reserve, we don't yet have an API to allow the opener of the + // channel to have 0-reserve. + if allow_0reserve { + assert_eq!(node_b.list_channels()[0].unspendable_punishment_reserve, Some(0)); + assert_eq!(node_b.list_channels()[0].outbound_capacity_msat, push_msat); + assert_eq!(node_b.list_channels()[0].next_outbound_htlc_limit_msat, push_msat); + + assert_eq!(node_b.list_balances().total_lightning_balance_sats * 1000, push_msat); + let LightningBalance::ClaimableOnChannelClose { amount_satoshis, .. } = + node_b.list_balances().lightning_balances[0] + else { + panic!("Unexpected `LightningBalance` variant"); + }; + assert_eq!(amount_satoshis * 1000, push_msat); + } + 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()); @@ -1267,6 +1295,39 @@ pub(crate) async fn do_channel_full_cycle( 2 ); + if allow_0reserve { + let node_a_outbound_capacity_msat = node_a.list_channels()[0].outbound_capacity_msat; + let node_a_reserve_msat = + node_a.list_channels()[0].unspendable_punishment_reserve.unwrap() * 1000; + // TODO: Zero-fee commitment channels are anchor channels, but do not allocate any + // funds to the anchor, so this will need to be updated when we ship these channels + // in ldk-node. + let node_a_anchors_msat = if expect_anchor_channel { 2 * 330 * 1000 } else { 0 }; + let funding_amount_msat = node_a.list_channels()[0].channel_value_sats * 1000; + // Node B does not have any reserve, so we only subtract a few items on node A's + // side to arrive at node B's capacity + let node_b_capacity_msat = funding_amount_msat + - node_a_outbound_capacity_msat + - node_a_reserve_msat + - node_a_anchors_msat; + let got_capacity_msat = node_b.list_channels()[0].outbound_capacity_msat; + assert_eq!(got_capacity_msat, node_b_capacity_msat); + assert_ne!(got_capacity_msat, 0); + // Sanity check to make sure this is a non-trivial amount + assert!(got_capacity_msat > 15_000_000); + + // This is a private channel, so node B can send 100% of the value over + assert_eq!(node_b.list_channels()[0].next_outbound_htlc_limit_msat, node_b_capacity_msat); + + node_b.spontaneous_payment().send(node_b_capacity_msat, node_a.node_id(), None).unwrap(); + expect_event!(node_b, PaymentSuccessful); + expect_event!(node_a, PaymentReceived); + + node_a.spontaneous_payment().send(node_b_capacity_msat, node_b.node_id(), None).unwrap(); + expect_event!(node_a, PaymentSuccessful); + expect_event!(node_b, PaymentReceived); + } + println!("\nB close_channel (force: {})", force_close); tokio::time::sleep(Duration::from_secs(1)).await; if force_close { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 9d1f99bed..35a6c9b3e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -48,8 +48,17 @@ async fn channel_full_cycle() { 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, true, false); - do_channel_full_cycle(node_a, node_b, &bitcoind.client, &electrsd.client, false, true, false) - .await; + do_channel_full_cycle( + node_a, + node_b, + &bitcoind.client, + &electrsd.client, + false, + false, + true, + false, + ) + .await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -57,8 +66,17 @@ async fn channel_full_cycle_force_close() { 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, true, false); - do_channel_full_cycle(node_a, node_b, &bitcoind.client, &electrsd.client, false, true, true) - .await; + do_channel_full_cycle( + node_a, + node_b, + &bitcoind.client, + &electrsd.client, + false, + false, + true, + true, + ) + .await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -66,8 +84,17 @@ async fn channel_full_cycle_force_close_trusted_no_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, true, true); - do_channel_full_cycle(node_a, node_b, &bitcoind.client, &electrsd.client, false, true, true) - .await; + do_channel_full_cycle( + node_a, + node_b, + &bitcoind.client, + &electrsd.client, + false, + false, + true, + true, + ) + .await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -75,8 +102,17 @@ async fn channel_full_cycle_0conf() { 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, true, true, false); - do_channel_full_cycle(node_a, node_b, &bitcoind.client, &electrsd.client, true, true, false) - .await; + do_channel_full_cycle( + node_a, + node_b, + &bitcoind.client, + &electrsd.client, + true, + false, + true, + false, + ) + .await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -84,8 +120,53 @@ async fn channel_full_cycle_legacy_staticremotekey() { 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, false); - do_channel_full_cycle(node_a, node_b, &bitcoind.client, &electrsd.client, false, false, false) - .await; + do_channel_full_cycle( + node_a, + node_b, + &bitcoind.client, + &electrsd.client, + false, + false, + false, + false, + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn channel_full_cycle_0reserve() { + 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, true, false); + do_channel_full_cycle( + node_a, + node_b, + &bitcoind.client, + &electrsd.client, + false, + true, + true, + false, + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn channel_full_cycle_0conf_0reserve() { + 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, true, true, false); + do_channel_full_cycle( + node_a, + node_b, + &bitcoind.client, + &electrsd.client, + true, + true, + true, + false, + ) + .await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] diff --git a/tests/integration_tests_vss.rs b/tests/integration_tests_vss.rs index 32226a8b0..210e9a8b2 100644 --- a/tests/integration_tests_vss.rs +++ b/tests/integration_tests_vss.rs @@ -54,6 +54,7 @@ async fn channel_full_cycle_with_vss_store() { &bitcoind.client, &electrsd.client, false, + false, true, false, ) From d780eae25a1da2961d2e72caa64832439502f824 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sat, 11 Apr 2026 01:55:18 +0000 Subject: [PATCH 2/3] Improve documentation on zero reserve channels --- src/lib.rs | 8 ++++---- src/liquidity.rs | 10 +++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 187600610..4d045657e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1449,8 +1449,8 @@ impl Node { /// Connect to a node and open a new unannounced channel, in which the target node can /// spend its entire balance. /// - /// This channel allows the target node to try to steal your funds with no financial - /// penalty, so this channel should only be opened to nodes you trust. + /// This channel allows the target node to try to steal your channel balance with no + /// financial penalty, so this channel should only be opened to nodes you trust. /// /// Disconnects and reconnects are handled automatically. /// @@ -1484,8 +1484,8 @@ impl Node { /// minus fees and anchor reserves. The target node will be able to spend its entire channel /// balance. /// - /// This channel allows the target node to try to steal your funds with no financial - /// penalty, so this channel should only be opened to nodes you trust. + /// This channel allows the target node to try to steal your channel balance with no + /// financial penalty, so this channel should only be opened to nodes you trust. /// /// Disconnects and reconnects are handled automatically. /// diff --git a/src/liquidity.rs b/src/liquidity.rs index 5dbd35b62..a36922cda 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -142,9 +142,13 @@ pub struct LSPS2ServiceConfig { /// /// [`bLIP-52`]: https://github.com/lightning/blips/blob/master/blip-0052.md#trust-models pub client_trusts_lsp: bool, - /// When set, clients that we open channels to will be allowed to spend their entire channel - /// balance. This allows clients to try to steal your funds with no financial penalty, so - /// this should only be set if you trust your clients. + /// When set, we will allow clients to spend their entire channel balance in the channels + /// we open to them. This allows clients to try to steal your channel balance with + /// no financial penalty, so this should only be set if you trust your clients. + /// + /// See [`Node::open_0reserve_channel`] to manually open these channels. + /// + /// [`Node::open_0reserve_channel`]: crate::Node::open_0reserve_channel pub allow_client_0reserve: bool, } From fe7786829cf3837842aa693d9b130ed603754d40 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 15 Apr 2026 03:30:23 +0000 Subject: [PATCH 3/3] Rename field to `LSPS2ServiceConfig::disable_client_reserve` This makes it consistent with the argument used in ldk-server's open channel API. Also add a few more variable renames to make things consistent. --- src/lib.rs | 6 +++--- src/liquidity.rs | 6 +++--- tests/common/mod.rs | 8 ++++---- tests/integration_tests_rust.rs | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4d045657e..dd82c39f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1149,7 +1149,7 @@ impl Node { fn open_channel_inner( &self, node_id: PublicKey, address: SocketAddress, channel_amount_sats: FundingAmount, push_to_counterparty_msat: Option, channel_config: Option, - announce_for_forwarding: bool, set_0reserve: bool, + announce_for_forwarding: bool, disable_counterparty_reserve: bool, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); @@ -1219,7 +1219,7 @@ impl Node { .expect("a 16-byte slice should convert into a [u8; 16]"), ); - let result = if set_0reserve { + let result = if disable_counterparty_reserve { self.channel_manager.create_channel_to_trusted_peer_0reserve( peer_info.node_id, channel_amount_sats, @@ -1239,7 +1239,7 @@ impl Node { ) }; - let zero_reserve_string = if set_0reserve { "0reserve " } else { "" }; + let zero_reserve_string = if disable_counterparty_reserve { "0reserve " } else { "" }; match result { Ok(_) => { diff --git a/src/liquidity.rs b/src/liquidity.rs index a36922cda..9f02af886 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -149,7 +149,7 @@ pub struct LSPS2ServiceConfig { /// See [`Node::open_0reserve_channel`] to manually open these channels. /// /// [`Node::open_0reserve_channel`]: crate::Node::open_0reserve_channel - pub allow_client_0reserve: bool, + pub disable_client_reserve: bool, } pub(crate) struct LiquiditySourceBuilder @@ -796,7 +796,7 @@ where config.channel_config.forwarding_fee_base_msat = 0; config.channel_config.forwarding_fee_proportional_millionths = 0; - let result = if service_config.allow_client_0reserve { + let result = if service_config.disable_client_reserve { self.channel_manager.create_channel_to_trusted_peer_0reserve( their_network_key, channel_amount_sats, @@ -823,7 +823,7 @@ where // the pending requests and regularly retry opening the channel until we // succeed. let zero_reserve_string = - if service_config.allow_client_0reserve { "0reserve " } else { "" }; + if service_config.disable_client_reserve { "0reserve " } else { "" }; log_error!( self.logger, "Failed to open LSPS2 {}channel to {}: {:?}", diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3f0c76f98..be9e16189 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -790,7 +790,7 @@ pub async fn splice_in_with_all( pub(crate) async fn do_channel_full_cycle( node_a: TestNode, node_b: TestNode, bitcoind: &BitcoindClient, electrsd: &E, allow_0conf: bool, - allow_0reserve: bool, expect_anchor_channel: bool, force_close: bool, + disable_node_b_reserve: bool, expect_anchor_channel: bool, force_close: bool, ) { let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -846,7 +846,7 @@ pub(crate) async fn do_channel_full_cycle( println!("\nA -- open_channel -> B"); let funding_amount_sat = 2_080_000; let push_msat = (funding_amount_sat / 2) * 1000; // balance the channel - if allow_0reserve { + if disable_node_b_reserve { node_a .open_0reserve_channel( node_b.node_id(), @@ -927,7 +927,7 @@ pub(crate) async fn do_channel_full_cycle( // Note that only node B has 0-reserve, we don't yet have an API to allow the opener of the // channel to have 0-reserve. - if allow_0reserve { + if disable_node_b_reserve { assert_eq!(node_b.list_channels()[0].unspendable_punishment_reserve, Some(0)); assert_eq!(node_b.list_channels()[0].outbound_capacity_msat, push_msat); assert_eq!(node_b.list_channels()[0].next_outbound_htlc_limit_msat, push_msat); @@ -1295,7 +1295,7 @@ pub(crate) async fn do_channel_full_cycle( 2 ); - if allow_0reserve { + if disable_node_b_reserve { let node_a_outbound_capacity_msat = node_a.list_channels()[0].outbound_capacity_msat; let node_a_reserve_msat = node_a.list_channels()[0].unspendable_punishment_reserve.unwrap() * 1000; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 35a6c9b3e..d2c057a16 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1786,7 +1786,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { min_channel_opening_fee_msat: 0, max_client_to_self_delay: 1024, client_trusts_lsp, - allow_client_0reserve: false, + disable_client_reserve: false, }; let service_config = random_config(true); @@ -2105,7 +2105,7 @@ async fn lsps2_client_trusts_lsp() { min_channel_opening_fee_msat: 0, max_client_to_self_delay: 1024, client_trusts_lsp: true, - allow_client_0reserve: false, + disable_client_reserve: false, }; let service_config = random_config(true); @@ -2280,7 +2280,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { min_channel_opening_fee_msat: 0, max_client_to_self_delay: 1024, client_trusts_lsp: false, - allow_client_0reserve: false, + disable_client_reserve: false, }; let service_config = random_config(true);