Open and accept zero reserve channels#4428
Open and accept zero reserve channels#4428tankyleo wants to merge 7 commits intolightningdevkit:mainfrom
Conversation
|
👋 Thanks for assigning @carlaKC as a reviewer! |
ffa1657 to
5fa3a7c
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #4428 +/- ##
==========================================
- Coverage 85.94% 85.91% -0.03%
==========================================
Files 159 159
Lines 104644 105103 +459
Branches 104644 105103 +459
==========================================
+ Hits 89934 90298 +364
- Misses 12204 12300 +96
+ Partials 2506 2505 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
TheBlueMatt
left a comment
There was a problem hiding this comment.
chanmon_consistency needs to be updated to have a 0-reserve channel or two (I believe we now have three channels between each pair of peers, so we can just do it on a subset of them, in fact we could have three separate channel types for better coverage).
| /// Creates a new outbound channel to the given remote node and with the given value. | ||
| /// | ||
| /// The only difference between this method and [`ChannelManager::create_channel`] is that this method sets | ||
| /// the reserve the counterparty must keep at all times in the channel to zero. This allows the counterparty to |
There was a problem hiding this comment.
nit: If that's the only difference let's say create_channel_to_trusted_peer_0_reserve? Nice to be explicit, imo.
lightning/src/ln/channel.rs
Outdated
|
|
||
| let channel_value_satoshis = | ||
| our_funding_contribution_sats.saturating_add(msg.common_fields.funding_satoshis); | ||
| // TODO(zero_reserve): support reading and writing the `disable_channel_reserve` field |
There was a problem hiding this comment.
Two questions. Shouldn't we check that if a channel has the 0-reserve feature bit and if it is fail if the user isn't accepting 0-reserve? Also why shouldn't we just set it now? I'm not sure we need to bother with a staging bit, really, honestly...
|
Needs rebase now :/ |
471ba8f to
253db4d
Compare
Let me know if you prefer I rebase first |
|
Feel free to go ahead and rebase and squash, yea. |
5fa3a7c to
43be438
Compare
|
Squash diff (do not click compare just above, I pushed the wrong branch, and later corrected it): |
43be438 to
7fde002
Compare
|
|
|
✅ Added second reviewer: @joostjager |
|
🔔 1st Reminder Hey @TheBlueMatt @joostjager! This PR has been waiting for your review. |
1 similar comment
|
🔔 1st Reminder Hey @TheBlueMatt @joostjager! This PR has been waiting for your review. |
|
🔔 2nd Reminder Hey @TheBlueMatt @joostjager! This PR has been waiting for your review. |
|
🔔 3rd Reminder Hey @TheBlueMatt @carlaKC! This PR has been waiting for your review. |
carlaKC
left a comment
There was a problem hiding this comment.
First pass - haven't gone through the tests yet.
lightning/src/sign/tx_builder.rs
Outdated
| let dust_limit_msat = dust_limit_satoshis.saturating_mul(1000); | ||
| if holder_balance_msat.saturating_sub(max_dust_htlc_msat) < dust_limit_msat | ||
| && counterparty_balance_msat < dust_limit_msat | ||
| && nondust_htlc_count == 0 |
There was a problem hiding this comment.
Can we re-use check_no_outputs here? Could also return early if we don't need any action.
We'd need to change it to a saturating_sub, but I think that's okay because we have a checked_sub check of our balances on 321 after calling check_no_outputs anyway?
There was a problem hiding this comment.
done below thank you !
lightning/src/sign/tx_builder.rs
Outdated
| // 1) The dust_limit_satoshis plus the fee of the exisiting commitment at the spiked feerate. | ||
| // 2) The fee of the commitment with an additional non-dust HTLC, aka the fee spike buffer HTLC. | ||
| // In this case we don't mind the holder balance output dropping below the dust limit, as | ||
| // this additional non-dust HTLC will create the single remaining output on the commitment. | ||
| let min_balance_msat = | ||
| cmp::max(dust_limit_satoshis + tx_fee_sat, fee_spike_buffer_sat) * 1000; |
There was a problem hiding this comment.
Comment for (1) says "at the spiked feerate" but isn't tx_fee_sat is just our current feerate?
A more descriptive name would be helpful here (if you can thing of something less gross than max_output_preserving_fee?).
There was a problem hiding this comment.
Comment for (1) says "at the spiked feerate" but isn't
tx_fee_satis just our current feerate?
See the callsites of the function; we always use the spiked_feerate as the feerate_per_kw parameter. I admit this is confusing, should I rename the function parameter to spiked_feerate ?
A more descriptive name would be helpful here (if you can thing of something less gross than max_output_preserving_fee?).
How is current_spiked_tx_fee_sat ? It would be consistent with the spiked_feerate style. With max_output_preserving_fee you want to highlight that we want to maintain an output on the commitment transaction even with a 2x increase in the feerate ?
There was a problem hiding this comment.
went ahead with both variable renames below
lightning/src/ln/channelmanager.rs
Outdated
| /// If it does not confirm before we decide to close the channel, or if the funding transaction | ||
| /// does not pay to the correct script the correct amount, *you will lose funds*. | ||
| /// | ||
| /// # Zero-reserve |
There was a problem hiding this comment.
Shouldn't we be setting the option_zero_reserve feature in lightning/bolts#1140?
There was a problem hiding this comment.
Discussed offline, I'll hold off on signaling for now pending further spec discussions
|
🔔 4th Reminder Hey @TheBlueMatt @carlaKC! This PR has been waiting for your review. |
|
Needs rebase already :/ |
lightning/src/ln/channel.rs
Outdated
| .expect("counterparty reserve is set") | ||
| == 0 | ||
| { | ||
| // If we previously had a 0-value reserve, continue with the same reserve |
There was a problem hiding this comment.
The comment says "If we previously had a 0-value reserve" but counterparty_selected_channel_reserve_satoshis is the reserve selected by the counterparty for us (i.e., the amount we must keep). If this was 0, it means the counterparty previously didn't require us to hold any reserve — not that "we had a 0-value reserve" in general. The comment is technically correct but could be clearer: e.g., "If the counterparty previously set our reserve to 0, continue with the same reserve."
Similarly, line 2801's comment says "If the counterparty previously had a 0-value reserve" for holder_selected_channel_reserve_satoshis — this is the reserve we selected for the counterparty. The phrasing is correct but could match the field semantics more precisely.
|
|
|
🔔 1st Reminder Hey @TheBlueMatt @carlaKC! This PR has been waiting for your review. |
|
🔔 5th Reminder Hey @TheBlueMatt @carlaKC! This PR has been waiting for your review. |
carlaKC
left a comment
There was a problem hiding this comment.
Went through the tx_builder changes together IRL, those lgtm.
Just one q on v2 and a few comments on breaking up the tests for readability.
| } | ||
| } | ||
|
|
||
| pub fn handle_and_accept_open_zero_reserve_channel( |
There was a problem hiding this comment.
(first time I posted this I think it got lost in the depths of GH).
Can we live without some of these helpers, because this accept step is the only one where we need to take a custom action?
Will add a few lines in do_test_accept_inbound_channel_from_trusted_peer_0reserve but the others are unaffected?
There was a problem hiding this comment.
done below thank you
| // We can't afford the fee for an additional non-dust HTLC + the fee spike HTLC, so we can only send | ||
| // dust HTLCs... | ||
| // We don't bother to add the second stage tx fees, these would only make this min bigger | ||
| let min_nondust_htlc_sat = dust_limit_satoshis; | ||
| assert!( | ||
| channel_value_sat | ||
| - commit_tx_fee_sat(spike_multiple * feerate_per_kw, 2, &channel_type) | ||
| < min_nondust_htlc_sat | ||
| ); | ||
| // But sending a big (not biggest) dust HTLC trims our balance output! | ||
| let max_dust_htlc = dust_limit_satoshis - 1; | ||
| assert!( | ||
| channel_value_sat - commit_tx_fee_sat(feerate_per_kw, 0, &channel_type) - max_dust_htlc | ||
| < dust_limit_satoshis | ||
| ); |
There was a problem hiding this comment.
Can we pull these asserts out of the channel type branching? If we use the correct fee rate / anchor values for each different type (eg, zero for 0FC) then they're the same?
There was a problem hiding this comment.
See the fixups below, let me know if the test now reads better. I made some overall cleanups to help with readability.
| let sender_amount_msat = (channel_value_sat - min_value_sat) * 1000; | ||
| let details_0 = &nodes[0].node.list_channels()[0]; | ||
| assert_eq!(details_0.next_outbound_htlc_minimum_msat, 1000); | ||
| assert_eq!(details_0.next_outbound_htlc_limit_msat, sender_amount_msat); | ||
| assert!( | ||
| details_0.next_outbound_htlc_limit_msat > details_0.next_outbound_htlc_minimum_msat | ||
| ); |
There was a problem hiding this comment.
Same here I think? Asserts are common for sender_amount_msat, we may just sometimes have zero anchors/fees somtimes.
There was a problem hiding this comment.
See the fixups below, as described above, I made a bigger reorg of the tests.
lightning/src/ln/channel.rs
Outdated
| // TODO(zero_reserve): support reading and writing the `disable_channel_reserve` field | ||
| let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( | ||
| channel_value_satoshis, msg.common_fields.dust_limit_satoshis); | ||
| let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( | ||
| channel_value_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS); | ||
| let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( | ||
| channel_value_satoshis, msg.common_fields.dust_limit_satoshis); |
There was a problem hiding this comment.
We've switched the dust limits used to floor channel reserve here - I think the counterparty's reserve should use the common_fields.dust_limit_satoshi and we should use MIN_CHAN_DUST_LIMIT_SATOSHIS for holder?
There was a problem hiding this comment.
We've switched the dust limits used to floor channel reserve here
Yes claude's review has been screaming at me asking for a separate commit, will do this if you are good too.
I think the counterparty's reserve should use the common_fields.dust_limit_satoshis
The counterparty's reserve / holder_selected_channel_reserve_satoshis, should not be smaller than the counterparty's dust limit, msg.common_fields.dust_limit_satoshis here.
we should use MIN_CHAN_DUST_LIMIT_SATOSHIS for holder?
The holder's reserve / counterparty_selected_channel_reserve_satoshis, should not me smaller than the holder's dust limit, MIN_CHAN_DUST_LIMIT_SATOSHIS here.
Let me know if we agree, I believe the current diff is correct.
| { | ||
| // If this dust HTLC produces no outputs, then we have to say something! It is now possible to produce a | ||
| // commitment with no outputs. | ||
| if !has_output( |
| nodes[1].node.list_channels()[0].next_outbound_htlc_limit_msat, | ||
| sender_amount_msat | ||
| ); | ||
| send_payment(&nodes[1], &[&nodes[0]], sender_amount_msat); |
There was a problem hiding this comment.
PaymentSucceeds is the only test case that's common to all of our channel types, and FailsReceiverUpdateAddHTLC has its own branch within the failure case here.
Wondering if these can be separated into different handlers with common setup to improve readability? Would also save our needing to check the channel type / NoOutputs combination above - just assert that ReceiverCanAcceptA/B is only running on static_remote_key, for example.
There was a problem hiding this comment.
See the fixups below, these would all get squashed into the test commit.
814f540 to
dee2218
Compare
lightning/src/ln/channel.rs
Outdated
| let counterparty_selected_channel_reserve_satoshis = if prev_funding | ||
| .counterparty_selected_channel_reserve_satoshis | ||
| .expect("counterparty reserve is set") | ||
| == 0 | ||
| { | ||
| // If we previously had a 0-value reserve, continue with the same reserve | ||
| 0 | ||
| } else { | ||
| get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS) | ||
| }; | ||
| let holder_selected_channel_reserve_satoshis = | ||
| if prev_funding.holder_selected_channel_reserve_satoshis == 0 { | ||
| // If the counterparty previously had a 0-value reserve, continue with the same reserve | ||
| 0 | ||
| } else { | ||
| get_v2_channel_reserve_satoshis( | ||
| post_channel_value, | ||
| context.counterparty_dust_limit_satoshis, | ||
| ) | ||
| }; |
There was a problem hiding this comment.
Bug: FundingScope::new_for_splice preserves zero reserves from the previous funding, but validate_splice_contributions (line 12303) always recalculates reserves using get_v2_channel_reserve_satoshis(post_channel_value, ...), which never returns 0. This means splice validation is stricter than the actual reserve for zero-reserve channels.
Concrete scenario: if a zero-reserve channel party has a post-splice balance less than the standard v2 reserve (e.g., 500 sats when the v2 reserve calculates to 546+), the splice will be rejected at validation time, even though the actual post-splice FundingScope would have reserve=0 and the splice would be valid.
validate_splice_contributions should check the previous funding's reserve values and propagate zero reserves the same way new_for_splice does.
lightning/src/ln/channelmanager.rs
Outdated
| user_channel_id: u128, accept_0conf: bool, accept_0reserve: bool, | ||
| config_overrides: Option<ChannelConfigOverrides>, |
There was a problem hiding this comment.
nit: Two adjacent bool parameters (accept_0conf, accept_0reserve) are easy to swap accidentally at call sites. Consider a small builder/options struct or at minimum adding // accept_0conf, accept_0reserve comments at every call site. Several existing call sites (e.g., chanmon_update_fail_tests.rs:3239) already read 0, true, false, None which is not self-documenting.
| &self, temporary_channel_id: &ChannelId, counterparty_node_id: &PublicKey, | ||
| user_channel_id: u128, config_overrides: Option<ChannelConfigOverrides>, | ||
| user_channel_id: u128, accept_0conf: bool, accept_0reserve: bool, | ||
| config_overrides: Option<ChannelConfigOverrides>, |
There was a problem hiding this comment.
Bug: accept_0reserve is silently ignored for V2/dual-funded channels. When the code path at the V2 branch (around line 10921) calls PendingV2Channel::new_inbound, it doesn't pass accept_0reserve — that function hardcodes is_0reserve = false (channel.rs line 14373-14375).
A user calling accept_inbound_channel_from_trusted_peer(id, node, 0, false, /*accept_0reserve=*/true, None) on a V2 channel open will believe they've set up zero-reserve, but the channel will be created with a normal 1% reserve.
Either:
- Pass
accept_0reservethrough toPendingV2Channel::new_inboundand wire it into the reserve calculation, or - Return an
APIError::APIMisuseErrorwhenaccept_0reserve == trueand the channel is V2, so the caller knows their intent was not honored.
| random_bytes | ||
| .copy_from_slice(&$dest_keys_manager.get_secure_random_bytes()[..16]); | ||
| let user_channel_id = u128::from_be_bytes(random_bytes); | ||
| $dest | ||
| .accept_inbound_channel( | ||
| temporary_channel_id, | ||
| counterparty_node_id, | ||
| user_channel_id, | ||
| None, | ||
| ) | ||
| .unwrap(); | ||
| if $trusted_accept { | ||
| $dest | ||
| .accept_inbound_channel_from_trusted_peer( | ||
| temporary_channel_id, | ||
| counterparty_node_id, | ||
| user_channel_id, | ||
| false, | ||
| true, | ||
| None, | ||
| ) | ||
| .unwrap(); | ||
| } else { | ||
| $dest |
There was a problem hiding this comment.
nit: The $trusted_accept branch calls accept_inbound_channel_from_trusted_peer with (accept_0conf=false, accept_0reserve=true), which means the zero-reserve acceptance path never exercises zero-conf. Consider also fuzzing the combination (true, true) on at least one channel to cover the zero-conf + zero-reserve interaction path that test_zero_reserve_zero_conf_combined tests.
There was a problem hiding this comment.
leaving this to a follow-up to this PR
cde4d01 to
f56ae64
Compare
| channel_value_satoshis: u64, push_msat: u64, user_id: u128, config: &UserConfig, | ||
| current_chain_height: u32, outbound_scid_alias: u64, | ||
| temporary_channel_id: Option<ChannelId>, logger: L, | ||
| trusted_channel_features: Option<TrustedChannelFeatures>, |
There was a problem hiding this comment.
Bug: The signature change from bool to Option<TrustedChannelFeatures> here was not propagated to the two #[cfg(ldk_test_vectors)] test functions that call OutboundV1Channel::new:
outbound_commitment_test()— currently passesfalse(around line 17032)zero_fee_commitment_test_vectors()— currently passesfalse(around line 17757)
Both need to be updated to pass None instead of false. This won't be caught in normal CI since the tests are gated on #[cfg(ldk_test_vectors)], but will cause a compilation failure when running test vectors.
The goal is to prevent any commitments with no outputs, since these are not broadcastable.
This new flag sets 0-reserve for the channel opener.
This new method sets 0-reserve for the channel accepter.
Co-Authored-By: HAL 9000
`ChannelContext::do_accept_channel_checks`, `ChannelContext::new_for_outbound_channel`, `ChannelContext::new_for_inbound_channel`, `InboundV1Channel::new`, `OutboundV1Channel::new`.
f56ae64 to
3542a15
Compare
|
Highlights from previous review:
The branch with fixups for these items is here https://github.com/tankyleo/rust-lightning/releases/tag/2026-03-19-zero-reserve-fixups |
| pub enum ChanType { | ||
| Legacy, | ||
| KeyedAnchors, | ||
| ZeroFeeCommitments, | ||
| } |
There was a problem hiding this comment.
nit: Consider adding #[derive(Clone, Copy)] (or at least Copy) on this fieldless enum. While Rust allows matching without Copy on fieldless enums (it reads only the discriminant), having Copy makes the intent explicit and avoids confusion for future maintainers. It also enables patterns like let ct = chan_type; if ever needed.
| fn new_for_inbound_channel<'a, ES: EntropySource, F: FeeEstimator, L: Logger>( | ||
| fee_estimator: &'a LowerBoundedFeeEstimator<F>, | ||
| entropy_source: &'a ES, | ||
| signer_provider: &'a SP, | ||
| counterparty_node_id: PublicKey, | ||
| their_features: &'a InitFeatures, | ||
| user_id: u128, | ||
| config: &'a UserConfig, | ||
| current_chain_height: u32, | ||
| logger: &'a L, | ||
| is_0conf: bool, | ||
| our_funding_satoshis: u64, | ||
| counterparty_pubkeys: ChannelPublicKeys, | ||
| channel_type: ChannelTypeFeatures, | ||
| holder_selected_channel_reserve_satoshis: u64, | ||
| msg_channel_reserve_satoshis: u64, | ||
| msg_push_msat: u64, | ||
| open_channel_fields: msgs::CommonOpenChannelFields, | ||
| fee_estimator: &'a LowerBoundedFeeEstimator<F>, entropy_source: &'a ES, | ||
| signer_provider: &'a SP, counterparty_node_id: PublicKey, their_features: &'a InitFeatures, | ||
| user_id: u128, config: &'a UserConfig, current_chain_height: u32, logger: &'a L, | ||
| trusted_channel_features: Option<TrustedChannelFeatures>, our_funding_satoshis: u64, |
There was a problem hiding this comment.
nit: The #[rustfmt::skip] removal on new_for_inbound_channel and new_for_outbound_channel reformats ~500 lines in the diff, burying the actual semantic changes (the trusted_channel_features parameter, zero-reserve validation relaxation, and minimum_depth logic). Consider splitting the formatting into a separate commit (the last commit message mentions formatting create_channel_internal but not these two functions).
| config.reject_inbound_splices = false; | ||
| if !anchors { | ||
| config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false; | ||
| match chan_type { |
There was a problem hiding this comment.
I don't think it is good to build out this way of increasing coverage. If chan type is part of the fuzz bytes, the fuzzer is able to zoom in on type-specific code paths.
Fixes #1801