Open and accept zero reserve channels#4428
Open and accept zero reserve channels#4428tankyleo wants to merge 8 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 86.18% 86.14% -0.05%
==========================================
Files 160 160
Lines 107536 108071 +535
Branches 107536 108071 +535
==========================================
+ Hits 92680 93094 +414
- Misses 12231 12353 +122
+ Partials 2625 2624 -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/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
| &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.
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 |
fuzz/src/chanmon_consistency.rs
Outdated
| 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.
There was a problem hiding this comment.
thanks for taking a look ! see the commit below
| } | ||
|
|
||
| pub fn chanmon_consistency_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) { | ||
| do_test(data, out.clone(), false); | ||
| do_test(data, out, true); | ||
| do_test(data, out); |
There was a problem hiding this comment.
The previous code ran do_test twice per fuzz input — once with anchors=false, once with anchors=true — guaranteeing every corpus entry exercised both channel type families. The new code runs do_test only once, with the channel type extracted from the fuzz input byte.
This means existing fuzz corpus entries that were evolved to cover interesting states under the two-pass model now only exercise a single channel type (determined by whatever happens to be in bits 3-4 of byte 0). Corpus entries that were valuable because they triggered anchor-specific behaviour may now be routed to the Legacy path or vice-versa, effectively discarding coverage.
Consider keeping the multi-pass structure by iterating over ChanType variants, or at minimum re-minimizing the corpus after this change.
| override_config, | ||
| Some(TrustedChannelFeatures::ZeroReserve), | ||
| ) | ||
| } |
There was a problem hiding this comment.
create_channel_to_trusted_peer_0reserve hardcodes TrustedChannelFeatures::ZeroReserve, meaning there's no API for opening an outbound channel with both zero-conf and zero-reserve. The accept side has ZeroConfZeroReserve, but the open side has no counterpart.
This asymmetry means a user who wants to create a zero-reserve channel and also trust the counterparty for zero-conf funding (e.g., a mutual trust scenario) can't do so from the opener side. They'd need to call create_channel_internal directly, which is private.
Consider either:
- Adding a
trusted_channel_featuresparameter tocreate_channel_to_trusted_peer_0reserve, or - Adding a separate
create_channel_to_trusted_peerthat takesTrustedChannelFeaturesdirectly (analogous to the accept side's API).
| // Remember we've got no non-dust HTLCs on the commitment here | ||
| let current_spiked_tx_fee_sat = commit_tx_fee_sat(spiked_feerate, 0, channel_type); | ||
| let spike_buffer_tx_fee_sat = commit_tx_fee_sat(spiked_feerate, 1, channel_type); |
There was a problem hiding this comment.
If is_outbound_from_holder = false, do we need to consider fees here at all?
Because the fee wouldn't be coming off our balance.
There was a problem hiding this comment.
yes ! thank you carla for catching this
| local_balance_before_fee_msat, | ||
| remote_balance_before_fee_msat, | ||
| remote_nondust_htlc_count, | ||
| spiked_feerate, |
There was a problem hiding this comment.
Just confirming my own understanding:
- Sender side: we check
has_outputagainst ourspiked_feerate, a higher feerate makes us more likely to think that a HTLC is dust, and thus more likely to think that we'll have no outputs. - Receiver side: we check
has_outputagainst ourfeerate_per_kw, a lower feerate makes us less likely to think that a HTLC is dust, and thus less likely to think that we'll have no outputs.
Send conservatively / receive permissively 👌
When we receive HTLCs from other impls, we're good provided that they have checked that the HTLC will result in our having outputs at the current commitment feerate. This means we won't run into funny force close issues if they happen to have a different spiked_feerate to us (which would be odd, but could happen).
There was a problem hiding this comment.
Yes overall SGTM, thanks for checking.
Note that receiver side we've got two checks: the least permissive one is at validate_update_add_hltc, which checks outputs against the current feerate_per_kw, and fails the channel in case of failure. The stricter one happens at can_accept_incoming_htlc, where we check the remote commitment against 2 * feerate_per_kw, and fail the individual HTLC in case of failure, not the full channel.
From the spec, spiked_feerate is recommended to be 2 * feerate_per_kw, so we should be in sync with other implementations.
| /// Note that there is no guarantee that the counterparty accepts such a channel themselves. | ||
| ZeroReserve, | ||
| /// Sets combination of [`TrustedChannelFeatures::ZeroConf`] and [`TrustedChannelFeatures::ZeroReserve`] | ||
| ZeroConfZeroReserve, |
There was a problem hiding this comment.
nit: slight preference to just have a vec<TrustedChannelFeatures> rather than try express combinations in an enum, but I don't think we're likely to add more options here so non-blocking.
There was a problem hiding this comment.
Hmm on the public API, I'd rather keep things as constrained as possible, which the enum does. The Vec<TrustedChannelFeatures> route is more open-ended, and hence more open to misunderstanding / misuse.
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.
Separate commit sgtm, agree on the dust limits you've stated 👌
| let feerate_per_kw = details.feerate_sat_per_1000_weight.unwrap(); | ||
| let anchors_sat = | ||
| if channel_type == ChannelTypeFeatures::anchors_zero_htlc_fee_and_dependencies() { | ||
| 2 * 330 | ||
| } else { | ||
| 0 | ||
| }; | ||
| let spike_multiple = if channel_type == ChannelTypeFeatures::only_static_remote_key() { | ||
| FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE as u32 | ||
| } else { | ||
| 1 | ||
| }; | ||
| let spiked_feerate = spike_multiple * feerate_per_kw; | ||
| let reserved_commit_tx_fee_sat = chan_utils::commit_tx_fee_sat( | ||
| spiked_feerate, | ||
| 2, // We reserve space for two HTLCs, the next outbound non-dust HTLC, and the fee spike buffer HTLC | ||
| &channel_type, | ||
| ); | ||
|
|
||
| let max_outbound_htlc_sat = | ||
| channel_value_sat - anchors_sat - reserved_commit_tx_fee_sat - reserve_sat; |
There was a problem hiding this comment.
if you don't mind, I'd like to leave it as is: requiring people to jump to a different function to understand these two tests seems harder to understand for me. My own preference is to have the test "transcript" be easy to follow down the page instead of jumping to different functions.
The floor for *our* selected reserve is *their* dust limit.
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`.
5fa3a7c to
6a49cf6
Compare
|
Fixes #1801